diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js
index 1e23bbd010..1ccf73f209 100644
--- a/.claude/workflows/docs-accuracy-audit.js
+++ b/.claude/workflows/docs-accuracy-audit.js
@@ -166,6 +166,7 @@ const ALL_HANDWRITTEN = [
"content/docs/permissions/sharing-rules.mdx",
"content/docs/permissions/sso.mdx",
"content/docs/permissions/system-context.mdx",
+ "content/docs/permissions/tenant-audit-census.mdx",
"content/docs/plugins/adding-a-metadata-type.mdx",
"content/docs/plugins/anatomy.mdx",
"content/docs/plugins/development.mdx",
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 245a237887..993f70a8e2 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1381,6 +1381,58 @@ jobs:
node scripts/check-system-context-census.mjs --self-test
node scripts/check-system-context-census.mjs
+ # #13548 tenant-audit census. `content/docs/permissions/tenant-audit-census.mdx`
+ # carries the population `SqlDriver.auditMissingTenant` acts on: every
+ # application-surface write call site against a tenancy-enabled object, and
+ # what execution context each one threads.
+ #
+ # ⭐ The reason it is an in-tree artefact at all is the failure it replaces.
+ # That census used to live in a COMMENT on an issue; the issue became
+ # unreachable (404 on three independent channels while its neighbours
+ # answer 200) and took the list with it, while three open cards still named
+ # it as their input and nothing noticed. What survived did so because a
+ # changeset author had happened to quote two of the figures in prose. A
+ # measurement that decides a repair family's severity and a ruling's scope
+ # is not a comment.
+ #
+ # ⚠️ It is a DRIFT gate, deliberately NOT an anchor gate like its `isSystem`
+ # sibling above. The generated region aggregates by (file, verb, object,
+ # tenancy, context posture) and carries no line numbers, so a pure
+ # displacement — an import inserted above a site — cannot move it. That
+ # skips the sibling's whole open defect class: anchors that rot on
+ # displacement, and a repair arm that then misreads displacement as a
+ # population change and asserts it on a security-relevant surface. There is
+ # exactly one repair path here (`node scripts/tenant-audit-census.mjs
+ # --write`), it is mechanical, and it has no case where it must guess.
+ #
+ # It lives in THIS job, next to its sibling, for the same reason that one
+ # does: the job has no paths filter, so the census runs on code-only PRs —
+ # the ones that ADD a write call site. The `--self-test` invocation is what
+ # `check:self-test-wired` requires, and it is the only instrument on this
+ # gate's matching rules; a clean tree cannot tell a working rule from a
+ # weakened one.
+ #
+ # ⭐ THREE commands, not two. The GENERATOR carries a self-test of its own
+ # because its classifiers are published FIGURES rather than findings: the
+ # gate's verdict is "no unplaceable receiver", and the elevation and
+ # tenant-context verdicts are not part of it. Nothing else reads them, so a
+ # clean tree cannot tell a working classifier from a weakened one. Both of
+ # the shapes it pins were measured WRONG on the first edition — a spread
+ # read as a denial of the flag it might carry, and an unreadable options
+ # argument folded into "carries no tenant context" — and six of its
+ # eighteen cases red against that reading.
+ #
+ # Invoked as `node` rather than through a `pnpm check:*` alias: see the
+ # GATE INVOCATION IDIOM note at the top of this file. AST walk over every
+ # tracked non-test source under the two roots plus the object registry, no
+ # spawns; ~8s. ⛔ The source COUNT is deliberately not quoted here: it is
+ # corpus scale, it moves on any file added under those roots, and a comment
+ # nothing checks is exactly where a stale number hides.
+ - name: Tenant-audit census matches the tree
+ run: |
+ node scripts/check-tenant-audit-census.mjs --self-test
+ node scripts/check-tenant-audit-census.mjs
+
# #12236 one
per doc page. `DocsTitle` renders the frontmatter `title`
# as the page's unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/
# page.tsx), so a body-level `# ` heading compiles to a SECOND one inside
diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx
index 49ed30ad3d..3e3c192e7b 100644
--- a/content/docs/permissions/index.mdx
+++ b/content/docs/permissions/index.mdx
@@ -67,6 +67,7 @@ alike — is [Declaring Capabilities](/docs/permissions/capabilities).
- [Social & Enterprise SSO](/docs/permissions/sso)
- [Authorization Architecture](/docs/permissions/authorization)
- [System Context (`isSystem`)](/docs/permissions/system-context) — what the platform's one elevation flag short-circuits, and what it deliberately does not
+- [Tenant-Audit Census](/docs/permissions/tenant-audit-census) — every application-surface write call site against a tenancy-enabled object, re-derived from the tree rather than quoted
- [Profiles (removed)](/docs/permissions/profiles)
- [Permission Sets](/docs/permissions/permission-sets)
- [Positions](/docs/permissions/positions)
diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json
index 9a09fc241f..0e252e8c1f 100644
--- a/content/docs/permissions/meta.json
+++ b/content/docs/permissions/meta.json
@@ -7,6 +7,7 @@
"sso",
"authorization",
"system-context",
+ "tenant-audit-census",
"profiles",
"permission-sets",
"positions",
diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx
new file mode 100644
index 0000000000..61bb8254ae
--- /dev/null
+++ b/content/docs/permissions/tenant-audit-census.mdx
@@ -0,0 +1,236 @@
+---
+title: Tenant-Audit Census
+description: The authoritative enumeration of every application-surface write call site against a tenancy-enabled object — how many thread an execution context, how many thread none, and how much of the population a static instrument can decide at all. Built by census over the whole repo, not by recall.
+---
+
+`SqlDriver.auditMissingTenant()` is the platform's one *diagnostic* on write-side
+tenancy. It warns when a write lands on a tenancy-enabled object without
+`options.tenantId` — the shape of a seed, migration or background path that
+forgot to thread the acting session.
+
+This page is **the authority** for the population that control acts on: every
+write call site on the application surface, what object it targets, and what
+execution context it threads. The figures below are generated, not written —
+rebuilt from the tree by `scripts/tenant-audit-census.mjs`, and
+`scripts/check-tenant-audit-census.mjs` fails the build when what is committed
+and what the tree says disagree.
+
+The per-site rows live next door, in
+`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`, and are written by
+the same command. The split is the one `packages/spec`'s strictness ledger
+already uses and is there for the same reason: this page has prose worth
+preserving and the row table has none, so the table is regenerated whole while
+the page keeps a small generated region for the figures its prose reasons about.
+
+## ⚠️ Why this page exists at all
+
+The measurement this page carries used to live in **a comment on an issue**. That
+issue became unreachable — it returns 404 on unauthenticated REST, on the rendered
+web page and on authenticated API access alike, while its immediate neighbours
+answer 200 — and it took the census with it. Three open cards named that comment
+as their input, and nothing anywhere noticed that their input had gone.
+
+What survived did so by luck. A changeset author had happened to quote two of the
+figures in prose, so "175 write call sites, 24 of them carrying no tenant context"
+is still readable on `main` in
+`.changeset/tenant-audit-update-delete-half-repairs.md` — while the list of 24
+that a downstream card was briefed to classify is not recoverable at all.
+
+⇒ A census that decides a repair family's severity and a ruling's scope is not a
+comment. It is a re-runnable instrument plus a committed page. Losing any issue
+now costs nothing, and the population is **re-derivable** rather than quoted.
+
+## What the control does, and what a call site controls
+
+`auditMissingTenant(object, op, options)` is gated in this order:
+
+1. `OS_TENANT_AUDIT=0` — off entirely;
+2. `options.bypassTenantAudit` — the engine sets this for **every**
+ `ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`);
+3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`;
+4. the deployment posture — the warning is only meaningful on a walled install;
+5. the object having a tenant field.
+
+Both of the guards a *caller* can move are downstream of one decision at the call
+site: **whether it threads an execution context at all**. Thread an elevated one
+and guard 2 silences the warning; thread a tenant-bearing one and guard 3 does;
+thread none and the site is what this census counts.
+
+## How the population is decided
+
+A site is a call to one of the three `IDataEngine` write doors — `insert`,
+`update`, `delete` — on a receiver whose declared type is an engine, in tracked
+non-test sources under `packages/services/` and `packages/plugins/`.
+
+**The verb name is not the signal.** `.delete()` alone answers some 250 call sites
+in this corpus, and most of them are `Map.delete`, `Set.delete`, `Headers.delete`,
+a crypto `Hash.update`, a blob-storage delete-by-key and a search-index de-index.
+So the receiver is typed structurally: a declared interface or type literal counts
+as an engine when it declares a write door whose first parameter is named
+`object` / `objectName` / `objectApiName` / `name` and typed `string` — the
+`IDataEngine` door signature. Interfaces that extend one inherit it; aliases that
+narrow one (`Partial>`) carry it.
+
+**The expensive failure direction is a keyword.** Sites whose receiver the author
+typed `any` have no type to read, and there are 45 of them — better than a fifth
+of the population, concentrated in exactly the seed and bootstrap paths this
+control exists for. Scoring an unreadable receiver as "not an engine" would have
+dropped every one of them silently, with a clean exit and a smaller number that
+reads exactly like a smaller truth. So `any` is not a classification here: it goes
+to the unreadable pile, and the unreadable pile is placed by facts about the tree
+(the argument names a declared object; the argument is a parameter declared
+`object: string`) or by a written `UNTYPED_RECEIVERS` row. **An unreadable
+receiver that none of the three place is an error, never a default.**
+
+Tenancy itself is enabled *by default* — `isTenancyDisabled()` reads
+`tenancy.enabled === false` and nothing else — so the object registry only has to
+find the opt-outs. Across 297 declared objects — the dated, ⛔ unenforced
+corpus-scale figure below — exactly two opt out (`sys_api_key`,
+`sys_sso_provider`), and no write call site on this surface targets either.
+
+## ⛔ What this instrument cannot decide
+
+A site whose object name is an inline literal or a local `const` is statically
+decidable. A site whose name is a parameter or a field — `ql.insert(object, …)`
+inside a shared seed helper, `this.objectName` on a per-object service — is not,
+and no amount of AST work makes it so: the object is chosen at run time. Those
+are reported as `undecidable` rather than assumed either way.
+
+The same holds twice over for the context. An options argument spelled as a
+literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
+forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
+context resolved from an inline literal or a local `const` can be tested for
+`isSystem`; one arriving from a helper call cannot.
+
+⇒ **A third of this population is undecidable in one dimension or another, and
+that is reported rather than smoothed over.** A census that quietly guessed on
+those sites would publish a number in the one direction this page cannot survive
+— claiming completeness while short — and a gate seeded from it would then hold
+the page to it.
+
+## ⚠️ Two over-claims this instrument made, and what they cost
+
+Both were found by the drift gate firing on this page's own artifact, and both
+are recorded here rather than quietly corrected, because the numbers they moved
+were published first.
+
+**The elevation classifier read a spread as a denial.** It walked an object
+literal's named properties looking for `isSystem` and returned "not elevated"
+when the loop ended — so `{ ...SYSTEM_CTX }` was scored **decidably NOT
+elevated** while every `SYSTEM_CTX` in the tree is `{ isSystem: true, … }`. It
+also never unwrapped `as const`, so a bare `context: SYSTEM_CTX` read as
+undecidable. Fixing both moved **51 sites' verdicts**: 45 from undecidable to
+elevated, and 6 from *decidably not elevated* to *elevated* — an inversion, on a
+security-relevant classification. The count of decidably-not-elevated sites is
+now **0**: nothing on this surface threads a context that provably lacks the flag.
+
+**"No tenant context" counted sites it had not read.** An options argument the
+walker could not parse was folded into the same bucket as one it had read and
+found empty. That published **84 sites "carrying no tenant context at all"**
+when 17 said so and 67 were simply unread — an over-claim in the *alarming*
+direction, on the very figure this page tells other cards to cite. `carries` is
+now three-valued, and an unreadable argument can never contribute to the
+provable count.
+
+⭐ Both are the same shape as the failure this artefact exists for, wearing
+opposite hats: one scored an unread thing as *nothing to report*, the other
+scored an unread thing as *a finding*. `node
+scripts/check-tenant-audit-census.mjs --self-test` pins all of it — 18 classifier
+cases, 6 of which red against the old reading, driven from the gate's own
+self-test rather than from a flag on the generator.
+
+## ⭐ Deviations from the carried figures, untrimmed
+
+Four figures were carried forward from the lost census. They are **control inputs
+for this re-derivation, not targets**, and this instrument was not shaped to
+reproduce them. Where it disagrees, it disagrees on the page:
+
+| carried figure | where it survives | this census |
+| :--- | :--- | ---: |
+| 175 write call sites | quoted in the merged changeset | **215** |
+| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
+| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
+| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
+| 141 and 132, two independent re-derivations | the card that filed this work | — |
+
+**The differences are not reconciled, and deliberately so.** The old census's
+population definition is not recoverable — that is the whole reason this page
+exists — so no honest reading can attribute the gap to a specific cause. What can
+be stated is what this instrument counts, which is written above and re-runnable
+at any commit.
+
+Two structural facts do plausibly widen this reading against any hand or regex
+one, and both are counted in the generated tables below: the 45 sites reached
+through an erased (`any`) receiver, and the 37 that name their object through a
+`const` rather than inline. An instrument that read either the way a person does
+would report a smaller number and would not say so.
+
+The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
+figure has no surviving corroboration anywhere in the tree.** This census reads
+97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
+run-time fact — so the claim is neither confirmed nor refuted, and the honest
+answer is that a static reading cannot settle it.
+
+⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
+READ and holds no tenant context, against a decidably tenancy-enabled object.
+That is the control's provable yield surface. ⛔ Do not cite it as "the sites
+without tenant context" — **32 further sites** have an options argument this
+cannot read, and they are neither in nor out.
+
+{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */}
+
+## The measurement
+
+| what | count |
+| :--- | ---: |
+| write call sites on the application surface | **215** |
+| …whose object name is statically decidable | 143 |
+| …whose object name is chosen at run time | 72 |
+| …against an object with tenancy ENABLED | 143 |
+| …against an object that declares tenancy off | 0 |
+| threading a tenant context | 131 |
+| PROVABLY carrying none (options read, no context key) | **17** |
+| …of those, against a decidably tenancy-enabled object | **9** |
+| options argument UNREADABLE — may or may not carry one | 67 |
+| …of those, against a decidably tenancy-enabled object | 32 |
+| threading a decidably ELEVATED (`isSystem`) context | 97 |
+| threading a context that is decidably NOT elevated | 0 |
+| threading a context whose elevation is a run-time fact | 101 |
+
+| how the instrument reached the site | count |
+| :--- | ---: |
+| receiver carried a readable engine type | 170 |
+| receiver erased, placed by the object NAME | 19 |
+| receiver erased, placed by an `object: string` PARAMETER | 15 |
+| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |
+
+| object name spelled inline | 106 |
+| object name spelled through a `const` | 37 |
+| object name is an `object: string` parameter | 19 |
+| object name is some other run-time expression | 53 |
+
+The corpus walked is every tracked non-test source under `packages/services/`
+and `packages/plugins/`; calls to a same-named method on something that is not
+a data engine were subtracted. Every site is listed in
+[`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`](https://github.com/objectstack-ai/objectstack/blob/main/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md),
+regenerated by the same command.
+
+### Corpus scale — present and dated, ⛔ NOT enforced
+
+⛔ These four describe the CORPUS this census walked, not the population it
+certifies, and the gate deliberately does not hold them to the tree — a source
+file arriving anywhere under the two roots moves them while every verdict above
+holds still. They are required to be HERE and to say WHEN they were true;
+their values are not compared. The reasoning, and the measurement behind it,
+are in `scripts/check-tenant-audit-census.mjs`.
+
+Measured on 2026-08-31 at `fc8858a24`.
+
+| corpus scale (not enforced) | count |
+| :--- | ---: |
+| tracked non-test sources scanned | 534 |
+| engine-shaped types recognised | 56 |
+| declared objects in the registry | 297 |
+| same-named calls subtracted as non-engine | 119 |
+
+{/* END GENERATED: tenant-audit-census */}
diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
new file mode 100644
index 0000000000..7706b6adc2
--- /dev/null
+++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
@@ -0,0 +1,206 @@
+
+
+
+# Tenant-audit census — every write call site (generated)
+
+Every application-surface write call site against a tenancy-enabled object, as
+`scripts/tenant-audit-census.mjs` derives it from the tree. **The prose, the
+method and the deviations from the figures this replaced are on the page**
+(`content/docs/permissions/tenant-audit-census.mdx`); this file has no prose to
+preserve and is regenerated whole.
+
+⛔ **Never hand-patch a row or a number here** — fix the code, or the census, and
+regenerate. `scripts/check-tenant-audit-census.mjs` fails the build when this file
+and the tree disagree.
+
+Rows are aggregated by (file, verb, object, tenancy, context posture) and carry no
+line numbers, so a pure displacement cannot move them. Run the generator with
+`--json` for per-site `file:line`.
+
+⚠️ **On a merge conflict here, regenerate — never resolve by hand.** Two branches
+that each add a write call site produce rows git merges cleanly and totals that
+merge cleanly and WRONG. This file is deliberately NOT `merge=os-regen`: that
+driver resolves an artefact's `gen:`/`check:` scripts in `@objectstack/spec`
+only, and these are root-level tooling. The gate is the backstop — a wrongly
+merged file fails `check-tenant-audit-census`, so the error is loud rather than
+silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
+
+## Totals
+
+| Measure | Value |
+|---|---:|
+| Write call sites | 215 |
+| Object name statically decidable | 143 |
+| Object name chosen at run time | 72 |
+| Against a tenancy-enabled object | 143 |
+| Against an object declaring tenancy off | 0 |
+| Threading a tenant context | 131 |
+| Provably carrying none | 17 |
+| …and decidably tenancy-enabled | 9 |
+| Options argument unreadable | 67 |
+| …and decidably tenancy-enabled | 32 |
+| Threading a decidably elevated context | 97 |
+| Threading a decidably non-elevated context | 0 |
+| Threading a context of undecidable elevation | 101 |
+
+## Corpus scale — present and dated, ⛔ NOT enforced
+
+⛔ These four describe the CORPUS this census walked, not the population it
+certifies, and the gate deliberately does not hold them to the tree — a source
+file arriving anywhere under the two roots moves them while every verdict above
+holds still. They are required to be HERE and to say WHEN they were true;
+their values are not compared. The reasoning, and the measurement behind it,
+are in `scripts/check-tenant-audit-census.mjs`.
+
+Measured on 2026-08-31 at `fc8858a24`.
+
+| corpus scale (not enforced) | count |
+| :--- | ---: |
+| tracked non-test sources scanned | 534 |
+| engine-shaped types recognised | 56 |
+| declared objects in the registry | 297 |
+| same-named calls subtracted as non-engine | 119 |
+
+## Every site
+
+| file | verb | object | tenancy | tenant context | n |
+|---|---|---|---|---|---:|
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
+| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `update` | `sys_user` | enabled | options unreadable | 2 |
+| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
+| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `update` | `sys_user` | enabled | elevated | 1 |
+| `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 |
+| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_account` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | options unreadable | 3 |
+| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 8 |
+| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 |
+| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 |
+| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-auth/src/backfill-account-issuer.ts` | `update` | `sys_account` | enabled | elevated | 1 |
+| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `m` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | PROVABLY NONE | 5 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | options unreadable | 2 |
+| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | options unreadable | 5 |
+| `packages/plugins/plugin-auth/src/phone-sms-texts.ts` | `insert` | `sys_notification_template` | enabled | elevated | 1 |
+| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
+| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 |
+| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | elevated | 2 |
+| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 |
+| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 |
+| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | elevated | 1 |
+| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | elevated | 1 |
+| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_report_schedule` | enabled | elevated | 2 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_report_schedule` | enabled | elevated | 1 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_report_schedule` | enabled | elevated | 2 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_saved_report` | enabled | elevated | 1 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_saved_report` | enabled | elevated | 1 |
+| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_saved_report` | enabled | elevated | 2 |
+| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `insert` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `update` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 |
+| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts` | `delete` | `sys_metadata` | enabled | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 |
+| `packages/plugins/plugin-security/src/security-plugin.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 2 |
+| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 |
+| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 |
+| `packages/plugins/plugin-sharing/src/primary-bu-projection.ts` | `update` | `sys_user` | enabled | elevated | 2 |
+| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | options unreadable | 2 |
+| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | elevated | 1 |
+| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | elevated | 2 |
+| `packages/plugins/plugin-sharing/src/sharing-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_record_share` | enabled | options unreadable | 3 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_sharing_rule` | enabled | options unreadable | 1 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `insert` | `sys_sharing_rule` | enabled | elevated | 1 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `update` | `sys_sharing_rule` | enabled | elevated | 1 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `delete` | `sys_record_share` | enabled | elevated | 2 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `insert` | `sys_record_share` | enabled | elevated | 1 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `update` | `sys_record_share` | enabled | elevated | 1 |
+| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `insert` | `subscriptionsObject` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `update` | `subscriptionsObject` | undecidable | options unreadable | 1 |
+| `packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts` | `update` | `subscriptionsObject` | undecidable | options unreadable | 1 |
+| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 |
+| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
+| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 |
+| `packages/services/service-automation/src/flow-dispatch-store.ts` | `insert` | `sys_flow_dispatch` | enabled | elevated | 1 |
+| `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | elevated | 2 |
+| `packages/services/service-automation/src/suspended-run-store.ts` | `insert` | `sys_automation_run` | enabled | elevated | 2 |
+| `packages/services/service-automation/src/suspended-run-store.ts` | `update` | `sys_automation_run` | enabled | elevated | 2 |
+| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | PROVABLY NONE | 1 |
+| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | PROVABLY NONE | 1 |
+| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | PROVABLY NONE | 2 |
+| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | PROVABLY NONE | 1 |
+| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | PROVABLY NONE | 1 |
+| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | elevated | 1 |
+| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | elevated | 3 |
+| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | elevated | 1 |
+| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job_run` | enabled | elevated | 1 |
+| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | PROVABLY NONE | 1 |
+| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | PROVABLY NONE | 1 |
+| `packages/services/service-messaging/src/messaging-service.ts` | `update` | `RECEIPT_OBJECT` | undecidable | options unreadable | 1 |
+| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | options unreadable | 1 |
+| `packages/services/service-messaging/src/sql-http-outbox.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 4 |
+| `packages/services/service-messaging/src/sql-outbox.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | options unreadable | 5 |
+| `packages/services/service-queue/src/db-queue-adapter.ts` | `delete` | `sys_job_queue` | enabled | context, elevation undecidable | 2 |
+| `packages/services/service-queue/src/db-queue-adapter.ts` | `insert` | `sys_job_queue` | enabled | context, elevation undecidable | 1 |
+| `packages/services/service-queue/src/db-queue-adapter.ts` | `update` | `sys_job_queue` | enabled | context, elevation undecidable | 6 |
+| `packages/services/service-settings/src/config-change-audit.ts` | `insert` | `sys_audit_log` | enabled | context, elevation undecidable | 1 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | options unreadable | 2 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | options unreadable | 1 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | options unreadable | 1 |
+| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | PROVABLY NONE | 1 |
+| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 |
+| `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 3 |
+| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | options unreadable | 1 |
+| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | elevated | 1 |
+| `packages/services/service-storage/src/backfill-file-references.ts` | `insert` | `sys_file` | enabled | context, elevation undecidable | 1 |
+| `packages/services/service-storage/src/backfill-sys-file-organizations.ts` | `update` | `sys_file` | enabled | context, elevation undecidable | 1 |
+| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `insert` | `sys_file` | enabled | context, elevation undecidable | 1 |
+| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 2 |
+| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_file` | enabled | options unreadable | 1 |
+| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_file` | enabled | options unreadable | 1 |
+| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_file` | enabled | options unreadable | 1 |
+| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_upload_session` | enabled | options unreadable | 1 |
+| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_upload_session` | enabled | options unreadable | 1 |
+| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_upload_session` | enabled | options unreadable | 1 |
diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs
new file mode 100644
index 0000000000..ef7c1fa047
--- /dev/null
+++ b/scripts/check-tenant-audit-census.mjs
@@ -0,0 +1,795 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * check-tenant-audit-census -- holds `content/docs/permissions/tenant-audit-census.mdx`
+ * to the code it claims to enumerate.
+ *
+ * node scripts/check-tenant-audit-census.mjs
+ * node scripts/check-tenant-audit-census.mjs --self-test
+ *
+ * The repair arm is the GENERATOR, not this gate:
+ *
+ * node scripts/tenant-audit-census.mjs --write
+ *
+ * That page carries the population the `auditMissingTenant` control acts on. Its
+ * predecessor was a comment on an issue that later 404'd, taking the list with
+ * it while three open cards still named it as their input -- so the page exists
+ * to be re-derivable, and this gate exists so "re-derivable" is a property the
+ * build checks rather than a promise the page makes.
+ *
+ * ## ⭐ Why this is a DRIFT gate and not an anchor gate
+ *
+ * The sibling `isSystem` census page carries `file:line` anchors and a gate that
+ * resolves them. That shape earns something real -- prose rows that point at
+ * code -- and it pays for it twice:
+ *
+ * - the anchors rot on pure DISPLACEMENT. An inserted import above a site
+ * moves every anchor below it, so the page reds on an edit that changed
+ * nothing it measures.
+ * - its repair arm then has to tell displacement apart from a population
+ * change, and gets it wrong: a file whose ledger-excused citations also
+ * shifted is reported as "the POPULATION changed" when nothing about the
+ * population moved. That refusal asserts something FALSE on a
+ * security-relevant surface, in the one direction an author is instructed to
+ * trust.
+ *
+ * This page is a CENSUS rather than a behaviour reference, so it does not need
+ * anchors to be useful, and the whole failure class is avoidable by not having
+ * them. The generated region aggregates by (file, verb, object, tenancy, context
+ * posture) and carries no line numbers, which is invariant under displacement;
+ * the only thing that can move it is the population. `--json` on the generator
+ * still carries every site's `file:line` for anyone navigating to one.
+ *
+ * ⇒ There is exactly ONE repair path (`--write`), it is mechanical, and it has
+ * no case where it must guess. That is the property the anchor scheme cannot
+ * have, and it is why this gate is not a port of its sibling.
+ *
+ * ## The two checks
+ *
+ * A DRIFT both generated artefacts equal what the generator produces
+ * from the tree right now, byte-for-byte: the page's generated
+ * region, and the whole of the audit ledger
+ * `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`.
+ * This is the load-bearing one: it holds them to the CODE, in
+ * the census -> page direction, so a site that arrives with no
+ * row fails rather than going unnoticed. ⛔ The CORPUS-SCALE
+ * rows and their dated marker are masked out of the comparison
+ * -- they must be PRESENT and DATED, never equal. See the split
+ * at `UNENFORCED_SCALE_ROWS`.
+ * B PROSE every census-derived number the page states OUTSIDE that
+ * region equals the census. A pattern that matches NOTHING is
+ * an error, so a reworded page cannot silently stop being
+ * checked -- the page's headline and its own deviation table
+ * are hand-written, and a hand-written number is exactly the
+ * thing that goes stale first. Its corpus-scale counterpart is
+ * `UNENFORCED_PROSE_COUNTS`: required to be said, not to be right.
+ *
+ * ## ⭐ What is enforced, and what is deliberately not
+ *
+ * The two artefacts carry two kinds of number and they behave nothing alike.
+ * POPULATION -- every write call site, its row, and its tenancy/context verdict,
+ * i.e. everything in `census.totals` -- is enforced byte-for-byte and nothing
+ * about it was relaxed. CORPUS SCALE -- how many sources were walked, how many
+ * engine-shaped types recognised, how many objects declared, how many same-named
+ * calls subtracted -- is present, dated, and NOT compared.
+ *
+ * Adopted by maintainer ruling on 2026-08-31 (option A), following the precedent
+ * the sibling `check-system-context-census.mjs` set at `:77-84`. The full ruling,
+ * the falsified alternative at `:30`, and the measurement that draws the line
+ * where it is drawn are all at `UNENFORCED_SCALE_ROWS` below.
+ *
+ * Check B is not redundant with A. The generated region cannot lie, because it
+ * is rewritten; the prose around it is where a human writes a claim, and #13178's
+ * whole lesson is that a quoted number outlives the measurement it quoted.
+ *
+ * ## Refusals, never quiet passes (#4690)
+ *
+ * A page that cannot be read, a missing marker pair, a census with zero sites, a
+ * prose pattern that matches nothing, and any refusal the generator itself
+ * raises (an unplaceable receiver, a stale ledger row, an unparseable source)
+ * are all exit 1 naming what could not be read.
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { isEntrypoint } from './invoked-as.mjs';
+import {
+ BEGIN_MARKER,
+ COUNTS,
+ END_MARKER,
+ PAGE,
+ corpusScaleRows,
+ renderCountsFile,
+ renderGeneratedRegion,
+ runCensus,
+ selfTest as censusSelfTest,
+} from './tenant-audit-census.mjs';
+
+const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+/**
+ * The paths this gate operates on, written where `scripts/pm/dispatch-gates.mjs`
+ * can see them. Provenance ONLY: nothing in this gate reads this list, and every
+ * check below behaves exactly as it did without it.
+ *
+ * ## The gap this closes, measured rather than argued
+ *
+ * That tool builds a card's gate list by scanning each gate's own source for the
+ * path literals it operates on, and it refuses to FOLLOW a module that is itself
+ * a gate file. This gate spelled **zero** literals of its own and inherited all
+ * 22 from `tenant-audit-census.mjs` — until that module became a gate file in its
+ * own right, the moment CI began invoking its `--self-test` directly.
+ *
+ * ⇒ The inheritance was cut, and the loss is silent in exactly the way this repo
+ * treats as worse than a red: a lead that stops appearing is indistinguishable
+ * from a lead that was never earned. A PR touching `packages/services/**` — the
+ * population this census exists to count — would simply stop being told that
+ * this gate reads its diff. `dispatch-gates --self-test` catches it, by name:
+ * "promoting N module(s) to gate files subtracts no inherited hint from any
+ * other family — LOST: scripts/check-tenant-audit-census.mjs <- …".
+ *
+ * The two subtrees are the census corpus, and they cover every `UNTYPED_RECEIVERS`
+ * path the generator spells — `hintCovers('packages/services', …)` is true for all
+ * of them — so restoring the roots restores the whole inherited population rather
+ * than a sample of it. The two artefacts are here because a HAND-EDIT to either is
+ * precisely what this gate exists to reject, and that edit must derive it.
+ *
+ * ⚠️ Kept in sync by nothing but review, which is why it is provenance and never
+ * a lookup key: `PAGE` and `COUNTS` are imported for every real use below, and the
+ * corpus roots live in `SURFACE_ROOTS`. This list may only ever be a WIDER-or-equal
+ * restatement of those; a narrower one silently shrinks the gate's discoverability
+ * again, which is the defect above wearing a different hat.
+ */
+const ROOT_DIR_WATCH_HINTS = [
+ 'packages/services/**',
+ 'packages/plugins/**',
+ 'content/docs/permissions/tenant-audit-census.mdx',
+ 'docs/audits/2026-08-tenant-audit-write-call-sites.counts.md',
+];
+
+/**
+ * The census-derived numbers the page states in its own prose.
+ *
+ * Each row's `pattern` must match EXACTLY ONCE outside the generated region, and
+ * its capture must equal `expected(census)`. Zero matches is a failure, not a
+ * pass: that is what stops a reworded page from quietly falling out of scope.
+ *
+ * ⚠️ Matched against the prose with WHITESPACE COLLAPSED, so every pattern is
+ * written with single spaces and none of them can break on a re-wrap. The page is
+ * hard-wrapped at 80 columns; a pattern spanning two words is one reflow away
+ * from matching nothing, and "matches nothing" is a FAILURE here -- so a
+ * line-sensitive pattern would turn every cosmetic re-wrap into a red gate and
+ * teach the next author to loosen the rule. Reflow is not a semantic change and
+ * this gate must not treat it as one.
+ */
+export const PROSE_COUNTS = [
+ {
+ name: 'sites reached through an erased receiver',
+ pattern: /there are (\d+) of them/,
+ expected: (c) => c.totals.placedByObjectName + c.totals.placedByObjectNameParameter + c.totals.placedByLedger,
+ },
+ {
+ name: 'sites reached through an erased receiver (deviation section)',
+ pattern: /the (\d+) sites reached through an erased \(`any`\) receiver/,
+ expected: (c) => c.totals.placedByObjectName + c.totals.placedByObjectNameParameter + c.totals.placedByLedger,
+ },
+ {
+ name: 'sites naming their object through a const',
+ pattern: /and the (\d+) that name their object through a `const`/,
+ expected: (c) => c.totals.objectNameConst,
+ },
+ {
+ name: 'sites whose options argument is unreadable (limits section)',
+ pattern: /\*\*(\d+) of the \d+ sites are spelled that way\*\*/,
+ expected: (c) => c.totals.tenantContextUnreadable,
+ },
+ {
+ name: 'the population that unreadable share is of',
+ pattern: /\*\*\d+ of the (\d+) sites are spelled that way\*\*/,
+ expected: (c) => c.totals.writeCallSites,
+ },
+ {
+ name: 'decidably-not-elevated is now zero',
+ pattern: /decidably-not-elevated sites is now \*\*(\d+)\*\*/,
+ expected: (c) => c.totals.nonElevatedContext,
+ },
+ {
+ name: 'sites the over-claim published as carrying no context',
+ pattern: /published \*\*(\d+) sites "carrying no tenant context at all"\*\*/,
+ expected: (c) => c.totals.provablyNoTenantContext + c.totals.tenantContextUnreadable,
+ },
+ {
+ name: 'of those, the ones that actually said so',
+ pattern: /when (\d+) said so and \d+ were simply unread/,
+ expected: (c) => c.totals.provablyNoTenantContext,
+ },
+ {
+ name: 'of those, the ones that were merely unread',
+ pattern: /when \d+ said so and (\d+) were simply unread/,
+ expected: (c) => c.totals.tenantContextUnreadable,
+ },
+ {
+ name: 'deviation row: write call sites',
+ pattern: /\| 175 write call sites \|[^|]*\| \*\*(\d+)\*\* \|/,
+ expected: (c) => c.totals.writeCallSites,
+ },
+ {
+ name: 'deviation row: provable and tenancy-enabled',
+ pattern: /\| 24 carrying no tenant context \|[^|]*\| \*\*(\d+)\*\* provable and tenancy-enabled/,
+ expected: (c) => c.totals.tenancyEnabledProvablyNoContext,
+ },
+ {
+ name: 'deviation row: unreadable and tenancy-enabled',
+ pattern: /provable and tenancy-enabled; \*\*(\d+)\*\* more whose options argument is unreadable/,
+ expected: (c) => c.totals.tenancyEnabledContextUnreadable,
+ },
+ {
+ name: 'deviation row: statically decidable',
+ pattern: /\| \*\*(\d+) of \d+\*\* decidable, \*\*\d+\*\* undecidable \|/,
+ expected: (c) => c.totals.staticallyDecidableObjectName,
+ },
+ {
+ name: 'deviation row: population the decidable share is of',
+ pattern: /\| \*\*\d+ of (\d+)\*\* decidable, \*\*\d+\*\* undecidable \|/,
+ expected: (c) => c.totals.writeCallSites,
+ },
+ {
+ name: 'deviation row: undecidable',
+ pattern: /\| \*\*\d+ of \d+\*\* decidable, \*\*(\d+)\*\* undecidable \|/,
+ expected: (c) => c.totals.undecidableObjectName,
+ },
+ {
+ name: 'deviation row: decidably elevated',
+ pattern: /\*\*not reproduced\*\*: (\d+) decidably elevated/,
+ expected: (c) => c.totals.elevatedContext,
+ },
+ {
+ name: 'deviation row: decidably not elevated',
+ pattern: /decidably elevated, (\d+) decidably not/,
+ expected: (c) => c.totals.nonElevatedContext,
+ },
+ {
+ name: 'deviation row: elevation undecidable',
+ pattern: /decidably not, (\d+) undecidable \|/,
+ expected: (c) => c.totals.elevationUndecidable,
+ },
+ {
+ name: 'the elevated share restated in prose',
+ pattern: /This census reads (\d+) of \d+ \(\d+%\) as decidably elevated/,
+ expected: (c) => c.totals.elevatedContext,
+ },
+ {
+ name: 'the population that elevated share is of',
+ pattern: /This census reads \d+ of (\d+) \(\d+%\) as decidably elevated/,
+ expected: (c) => c.totals.writeCallSites,
+ },
+ {
+ name: 'the figure downstream cards should cite',
+ pattern: /Cite `(\d+) \/ \d+`/,
+ expected: (c) => c.totals.tenancyEnabledProvablyNoContext,
+ },
+ {
+ name: 'the population that figure is of',
+ pattern: /Cite `\d+ \/ (\d+)`/,
+ expected: (c) => c.totals.writeCallSites,
+ },
+ {
+ name: 'the further sites that are neither in nor out',
+ pattern: /\*\*(\d+) further sites\*\* have an options argument this cannot read/,
+ expected: (c) => c.totals.tenancyEnabledContextUnreadable,
+ },
+];
+
+// ---------------------------------------------------------------------------
+// ⭐ The enforced / unenforced split
+// ---------------------------------------------------------------------------
+
+/**
+ * ⛔ The corpus-scale numbers this gate deliberately does NOT hold to the tree,
+ * listed here so that stays a DECISION instead of an omission.
+ *
+ * Maintainer ruling, 2026-08-31, verbatim and untranslated:
+ *
+ * > **裁定**:采**选项 A** —— 租户审计普查工件的门禁采纳兄弟门禁
+ * > (`check-system-context-census.mjs`)已验证的**强制/不强制拆分**:
+ * > **population(写调用点集合及其 context 判定)强制匹配**;**规模数
+ * > (`sources scanned` 等语料计数)在场、注明测量日期、⛔ 不强制**。
+ *
+ * ## The precedent the ruling rests on
+ *
+ * The sibling `isSystem` census gate already carries this split, and its docblock
+ * (`scripts/check-system-context-census.mjs:77-84`) gives the reason:
+ *
+ * > *"The split is not a tolerance. Nothing about the CONTRACT stopped being
+ * > checked ... What stopped being checked is a set of numbers about a
+ * > population the page does not certify -- and whose churn, measured, was
+ * > blocking the page from ever landing."*
+ *
+ * ## ⛔ What was NOT adopted, and why it is closed
+ *
+ * Asserting against a RECORDED BASELINE SHA instead of the live tree was proposed
+ * and is falsified. The sibling's `:30` refutes it in one line:
+ *
+ * > *"a gate that only checks what the page already says can never find what
+ * > the page failed to say."*
+ *
+ * A gate pinned to a commit nobody reviews can no longer answer "a write call
+ * site arrived and nothing documents it" -- the #13178 shape. It does not remove
+ * staleness, it removes the alarm, and it does so wearing a green tick.
+ *
+ * ## ⭐ Why the split is drawn HERE, measured rather than argued
+ *
+ * Measured over the 60 most recent non-merge commits reachable from `967402a12`,
+ * by running this census at every one of the 16 that touch the corpus (the other
+ * 44 cannot move either artefact) and diffing the RENDERED artefacts line by line:
+ *
+ * artefacts drifted at all 3 of 59 transitions
+ * -- SCALE-ONLY (two lines, both 2 `sources scanned` 521->533->534,
+ * the sources-scanned figure) every population figure byte-identical
+ * -- REAL population drift 1 25 lines, none of them corpus scale
+ * (writeCallSites 214->215 and the
+ * verdict counts that moved with it)
+ *
+ * ⭐ The control is the whole argument, and it points both ways: on the two
+ * scale-moving commits the population held EXACTLY still, and on the one
+ * population-moving commit every corpus-scale number held EXACTLY still. The two
+ * classes did not move together once. So this split removes two of the three
+ * measured reds, and the two it removes are precisely the ones with no safety
+ * content -- while the one that matters still fails.
+ *
+ * ⚠️ It is necessary and NOT sufficient, and the honest number is smaller than
+ * the card that ordered it estimated: that card put `sources scanned` churn at
+ * 25% of commits. Re-measured, 26.7% of commits TOUCH the corpus but only 3.4%
+ * MOVE the count -- the classifier behind the 25% counted the touch, not the
+ * move. Residual population drift is ~1.7% of commits, which still lands inside
+ * a human-merge window; that is the "new measured data" the ruling's option-C
+ * paragraph asks to be brought back to the box, not something this gate fixes.
+ *
+ * ## What is still enforced, in full
+ *
+ * Every write call site and its row; every tenancy and context verdict; every
+ * figure inside `census.totals`; the whole aggregated site table; an empty census;
+ * a missing region; and every enforced prose figure. ⛔ Nothing security-relevant
+ * moved to this list. The line is the one the census data already draws:
+ * `census.totals.*` is the population the page certifies, and the four scalars
+ * beside it describe the corpus it walked.
+ *
+ * ⭐ The labels are derived from the GENERATOR's own `corpusScaleRows`, never
+ * copied: a fifth corpus number added there is covered here the moment it is
+ * emitted, and a renamed row cannot leave a stale pattern behind that matches
+ * nothing while reading as coverage.
+ */
+export const UNENFORCED_SCALE_ROWS = corpusScaleRows({
+ scannedSources: 0,
+ engineTypes: 0,
+ declaredObjects: 0,
+ nonEngineCalls: 0,
+}).map(([label]) => ({
+ id: label,
+ label,
+ pattern: new RegExp(`^\\| ${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\| (\\d+) \\|$`, 'm'),
+ canonical: `| ${label} | «unenforced» |`,
+}));
+
+/**
+ * The unenforced block must SAY WHEN IT WAS TRUE, and this gate holds it to that.
+ *
+ * ⚠️ A number nothing enforces rots silently -- which is the disease this whole
+ * artefact exists to treat, one level down. Four bare numbers that read as current
+ * and are checked by nothing would be a worse artefact than four that say when
+ * they were measured.
+ *
+ * ⛔ The date and the ref are deliberately NOT compared to anything. Requiring
+ * them to be recent would re-introduce exactly the churn this split removes.
+ */
+export const UNENFORCED_MEASURED_AT = {
+ pattern: /^Measured on (\d{4}-\d{2}-\d{2}) at `([0-9a-f]{7,40})`\.$/m,
+ why: 'the dated marker on the corpus-scale block',
+};
+
+/**
+ * Prose figures that are corpus scale rather than population.
+ *
+ * Same contract as the sibling's `UNENFORCED_TEXT_COUNTS`: each row is still
+ * REQUIRED TO MATCH, exactly once, so a reworded page cannot retire the rule by
+ * deleting a sentence. ⛔ What is dropped is only the COMPARISON. The date these
+ * carry is the one marker on the page's corpus-scale block.
+ */
+export const UNENFORCED_PROSE_COUNTS = [
+ {
+ name: 'declared objects in the registry',
+ pattern: /Across (\d+) declared objects/,
+ },
+];
+
+/**
+ * Blank the unenforced VALUES on both sides of the byte comparison.
+ *
+ * ⭐ This is where the split had to differ from its sibling, and the difference is
+ * structural rather than stylistic. The sibling enforces a HAND-WRITTEN page
+ * through a list of regexes, so it splits by simply not comparing some of them.
+ * This gate's enforced half is a BYTE-FOR-BYTE comparison of GENERATED text --
+ * there is no per-number comparison to drop. Adding an unenforced list beside the
+ * byte compare would have changed nothing: the byte compare still reds on the
+ * scale line. So the value is masked out of both texts and everything else stays
+ * byte-exact.
+ *
+ * The mask is anchored to the whole line and rewrites it WHOLE, so it cannot
+ * blank a digit that is part of a label, and a row whose shape changed simply
+ * fails to mask -- surfacing as drift rather than as a silent exemption.
+ */
+export function maskUnenforced(text) {
+ let out = text;
+ for (const row of UNENFORCED_SCALE_ROWS) {
+ out = out.replace(new RegExp(row.pattern.source, 'gm'), row.canonical);
+ }
+ return out.replace(
+ new RegExp(UNENFORCED_MEASURED_AT.pattern.source, 'gm'),
+ 'Measured on «unenforced» at `«unenforced»`.',
+ );
+}
+
+/** Split the page into its hand-written prose and its generated region. */
+export function splitPage(pageText) {
+ const begin = pageText.indexOf(BEGIN_MARKER);
+ const end = pageText.indexOf(END_MARKER);
+ if (begin === -1 || end === -1) {
+ return {
+ error:
+ `${PAGE} has no generated region -- expected the marker pair `
+ + '`BEGIN GENERATED: tenant-audit-census` / `END GENERATED: tenant-audit-census`. '
+ + 'Run `node scripts/tenant-audit-census.mjs --write` after restoring them.',
+ };
+ }
+ return {
+ prose: pageText.slice(0, begin) + pageText.slice(end + END_MARKER.length),
+ region: pageText.slice(begin, end + END_MARKER.length),
+ };
+}
+
+/** The first line at which two texts differ, for a refusal that names the drift. */
+function firstDifference(committed, expected) {
+ const a = committed.split('\n');
+ const b = expected.split('\n');
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
+ if (a[i] !== b[i]) {
+ return { line: i + 1, committed: a[i] ?? '(page ends here)', expected: b[i] ?? '(census ends here)' };
+ }
+ }
+ return null;
+}
+
+/**
+ * Run both checks against a census and a page text.
+ *
+ * Takes the page as TEXT rather than reading it, so the self-test can feed
+ * adversarial pages through the same code path the production run uses.
+ *
+ * @returns {string[]} problems, empty when clean.
+ */
+export function checkPage(census, pageText, countsText) {
+ const problems = [];
+
+ if (census.sites.length === 0) {
+ problems.push(
+ '[empty-census] the census found ZERO write call sites -- refusing to certify a page '
+ + 'against nothing (a walk that found nothing and a tree with nothing to find are different).',
+ );
+ return problems;
+ }
+
+ const split = splitPage(pageText);
+ if (split.error) {
+ problems.push(`[no-region] ${split.error}`);
+ return problems;
+ }
+ const prose = split.prose.replace(/\s+/g, ' ');
+
+ // ── A DRIFT ────────────────────────────────────────────────────────────────
+ // ⭐ Byte-for-byte on the POPULATION; value-blind on the CORPUS-SCALE rows and
+ // the dated marker. Those are masked on BOTH sides rather than dropped from a
+ // comparison list, because the comparison here is the whole text -- see
+ // `maskUnenforced`. Everything outside those lines is still exact.
+ for (const [label, committed, expected] of [
+ [PAGE, split.region, renderGeneratedRegion(census)],
+ [COUNTS, countsText, renderCountsFile(census)],
+ ]) {
+ // ⛔ "Not enforced" must not decay into "not there". The rows and their date
+ // are required to be PRESENT in the committed artefact; only their VALUES go
+ // unchecked. A row reworded off the artefact is a finding, never a pass.
+ for (const row of UNENFORCED_SCALE_ROWS) {
+ if (!row.pattern.test(committed ?? '')) {
+ problems.push(
+ `[unenforced-row-missing] ${label} no longer carries the \`${row.id}\` corpus-scale row. `
+ + 'It is deliberately not held to the tree, but it is still required to be there and '
+ + 'to be dated -- regenerate, or remove it from the generator\'s `corpusScaleRows` if '
+ + 'it is really gone, rather than leaving a number nobody can find.',
+ );
+ }
+ }
+ if (!UNENFORCED_MEASURED_AT.pattern.test(committed ?? '')) {
+ problems.push(
+ `[unenforced-undated] ${label} states ${UNENFORCED_SCALE_ROWS.length} corpus-scale number(s) `
+ + `this gate does not enforce and no longer says when they were measured `
+ + `(${UNENFORCED_MEASURED_AT.why}). An unenforced number without a date reads as current `
+ + 'and is checked by nothing -- restore the marker or delete the numbers.',
+ );
+ }
+
+ const maskedCommitted = maskUnenforced(committed ?? '');
+ const maskedExpected = maskUnenforced(expected);
+ if (maskedCommitted === maskedExpected) continue;
+ const diff = firstDifference(maskedCommitted, maskedExpected);
+ problems.push(
+ `[census-drift] the committed census in ${label} is not what the tree produces. `
+ + (diff
+ ? `First difference at line ${diff.line}:\n`
+ + ` committed : ${diff.committed}\n`
+ + ` census : ${diff.expected}\n`
+ : '')
+ + ' Fix: node scripts/tenant-audit-census.mjs --write',
+ );
+ }
+
+ // ── B PROSE ────────────────────────────────────────────────────────────────
+ for (const row of PROSE_COUNTS) {
+ const matches = [...prose.matchAll(new RegExp(row.pattern, 'g'))];
+ if (matches.length === 0) {
+ problems.push(
+ `[prose-pattern-dead] the page no longer states "${row.name}" in the shape this gate `
+ + `checks (${row.pattern}). A reworded page must not silently stop being checked -- `
+ + 'either restore the wording or update PROSE_COUNTS in this gate.',
+ );
+ continue;
+ }
+ if (matches.length > 1) {
+ problems.push(
+ `[prose-pattern-ambiguous] "${row.name}" matches ${matches.length} places in the page. `
+ + 'This gate checks one number per row; narrow the pattern.',
+ );
+ continue;
+ }
+ const stated = Number(matches[0][1]);
+ const actual = row.expected(census);
+ if (stated !== actual) {
+ problems.push(
+ `[prose-count] the page states ${stated} for "${row.name}"; the census says ${actual}. `
+ + 'The page is hand-written here -- a quoted number outliving its measurement is the '
+ + 'exact failure this artefact replaced.',
+ );
+ }
+ }
+
+ // ── B2 THE UNENFORCED PROSE FIGURES: present, never compared ───────────────
+ // ⛔ Their VALUES are not checked -- they are corpus scale, dated by the
+ // marker on the generated block. What IS checked is that the sentence still
+ // exists, so "not enforced" cannot be reached by deleting the claim.
+ for (const row of UNENFORCED_PROSE_COUNTS) {
+ const matches = [...prose.matchAll(new RegExp(row.pattern, 'g'))];
+ if (matches.length === 0) {
+ problems.push(
+ `[unenforced-prose-missing] the page no longer states "${row.name}" in the shape this gate `
+ + `looks for (${row.pattern}). It is deliberately not held to the census, but it is still `
+ + 'required to be on the page -- restore the wording, or drop the row from '
+ + 'UNENFORCED_PROSE_COUNTS if the claim is really gone.',
+ );
+ } else if (matches.length > 1) {
+ problems.push(
+ `[unenforced-prose-ambiguous] "${row.name}" matches ${matches.length} places in the page; `
+ + 'narrow the pattern so the row still names one claim.',
+ );
+ }
+ }
+
+ return problems;
+}
+
+// ---------------------------------------------------------------------------
+// Self-test -- the only instrument on this gate's matching rules
+// ---------------------------------------------------------------------------
+
+/**
+ * A clean tree cannot tell a working rule from a weakened one: green means the
+ * problem set is empty, and weakening a rule can only shrink that set. So both
+ * rules are driven here against pages a clean tree does not contain -- and the
+ * REAL census, so a rule that stops reading the tree fails here too.
+ */
+export function selfTest() {
+ const cases = [];
+ const t = (name, ok, detail) => cases.push({ name, ok: Boolean(ok), detail });
+
+ const census = runCensus();
+ const page = readFileSync(join(ROOT, PAGE), 'utf8');
+ const counts = readFileSync(join(ROOT, COUNTS), 'utf8');
+ const check = (p = page, c = counts) => checkPage(census, p, c);
+
+ t('the committed artefacts are clean', check().length === 0, check().join(' | '));
+
+ // ── A DRIFT ────────────────────────────────────────────────────────────────
+ // ⭐ A row deleted from the LEDGER -- "a site exists in code and no row names
+ // it", which is the direction that matters and the one a clean tree cannot show.
+ const countLines = counts.split('\n');
+ const firstRow = countLines.findIndex((l) => l.startsWith('| `packages/'));
+ t('a site that exists in code but has no row in the ledger is a finding',
+ firstRow !== -1
+ && check(page, [...countLines.slice(0, firstRow), ...countLines.slice(firstRow + 1)].join('\n'))
+ .some((p) => p.startsWith('[census-drift]')));
+
+ // A count changed in the generated region -- the shape a hand-edit takes.
+ t('an edited count inside the page region is a finding',
+ check(page.replace(
+ `| write call sites on the application surface | **${census.totals.writeCallSites}** |`,
+ `| write call sites on the application surface | **${census.totals.writeCallSites + 1}** |`,
+ )).some((p) => p.startsWith('[census-drift]')));
+
+ t('an edited total inside the ledger is a finding',
+ check(page, counts.replace(
+ `| Write call sites | ${census.totals.writeCallSites} |`,
+ `| Write call sites | ${census.totals.writeCallSites - 1} |`,
+ )).some((p) => p.startsWith('[census-drift]')));
+
+ // The markers themselves -- a page that lost its region must refuse, never pass.
+ t('a page with no generated region refuses',
+ check(page.replace(BEGIN_MARKER, '')).some((p) => p.startsWith('[no-region]')));
+
+ // ── B PROSE ────────────────────────────────────────────────────────────────
+ // ⚠️ Anchored on the BOLD RUN alone, never on the sentence: the page is hard
+ // wrapped at 80 columns and "... argument this\ncannot read" spans two lines,
+ // so a sentence-shaped needle silently replaces NOTHING and the case then
+ // passes a clean page off as a drifted one. The gate matches with whitespace
+ // collapsed; this self-test edits the raw file, and the two are not the same text.
+ const enforcedProse = (n) => `**${n} further sites**`;
+ t('a stale hand-written number in the prose is a finding',
+ check(page.replace(
+ enforcedProse(census.totals.tenancyEnabledContextUnreadable),
+ enforcedProse(census.totals.tenancyEnabledContextUnreadable - 1),
+ )).some((p) => p.startsWith('[prose-count]')));
+
+ t('a stale number in the DEVIATION table is a finding',
+ check(page.replace(
+ `| **${census.totals.writeCallSites}** |`,
+ `| **${census.totals.writeCallSites - 7}** |`,
+ )).some((p) => p.startsWith('[prose-count]') || p.startsWith('[census-drift]')));
+
+ // ⭐ The direction a clean tree cannot show: a page that stops SAYING the thing
+ // is not a page that passes. Rewording out of scope must red, or every prose
+ // rule can be retired by deleting a sentence.
+ t('a prose claim reworded out of the gate\'s reach is a finding, not a pass',
+ check(page.replace(
+ enforcedProse(census.totals.tenancyEnabledContextUnreadable),
+ '**many further sites**',
+ )).some((p) => p.startsWith('[prose-pattern-dead]')));
+
+ t('the "cite this figure" line is held to the census',
+ check(page.replace(
+ `Cite \`${census.totals.tenancyEnabledProvablyNoContext} / ${census.totals.writeCallSites}\``,
+ `Cite \`24 / ${census.totals.writeCallSites}\``,
+ )).some((p) => p.startsWith('[prose-count]')));
+
+ // ── ⭐ THE SPLIT ───────────────────────────────────────────────────────────
+ // The one direction a clean tree cannot show, in BOTH directions at once: the
+ // unenforced value may move without a finding, and everything around it may
+ // not. A gate that got this wrong in either direction would look identical on
+ // a clean tree.
+ const scaleRow = UNENFORCED_SCALE_ROWS[0];
+ const drift = (text, delta) => text.replace(
+ scaleRow.pattern,
+ (line, n) => `| ${scaleRow.label} | ${Number(n) + delta} |`,
+ );
+ t('⭐ a corpus-scale count that no longer matches the tree is NOT a finding (page)',
+ check(drift(page, 41), counts).length === 0,
+ check(drift(page, 41), counts).join(' | '));
+ t('⭐ a corpus-scale count that no longer matches the tree is NOT a finding (ledger)',
+ check(page, drift(counts, -17)).length === 0,
+ check(page, drift(counts, -17)).join(' | '));
+
+ // ⛔ ...and the control, without which the case above only proves the gate is
+ // blind: a POPULATION figure one line away from it still fails.
+ t('⭐ CONTROL: a population count in the same table IS still a finding',
+ check(page.replace(
+ `| write call sites on the application surface | **${census.totals.writeCallSites}** |`,
+ `| write call sites on the application surface | **${census.totals.writeCallSites + 41}** |`,
+ )).some((p) => p.startsWith('[census-drift]')));
+
+ // ⛔ "not enforced" must not decay into "not there".
+ t('an unenforced row deleted from the artefact is a finding, not a pass',
+ check(page.replace(scaleRow.pattern, ''), counts)
+ .some((p) => p.startsWith('[unenforced-row-missing]')));
+ t('an unenforced row deleted from the LEDGER is a finding, not a pass',
+ check(page, counts.replace(scaleRow.pattern, ''))
+ .some((p) => p.startsWith('[unenforced-row-missing]')));
+
+ // ⛔ ...nor into "undated". An unenforced number with no date reads as current.
+ t('unenforced numbers with no measurement date are a finding, not a quiet pass',
+ check(page.replace(UNENFORCED_MEASURED_AT.pattern, 'Measured at some point.'), counts)
+ .some((p) => p.startsWith('[unenforced-undated]')));
+
+ // ⭐ The date and the ref are deliberately NOT compared -- an old marker is the
+ // point of the marker, not a finding.
+ t('⭐ a measurement date from months ago is NOT a finding',
+ check(page.replace(UNENFORCED_MEASURED_AT.pattern, 'Measured on 2001-01-01 at `0000000ab`.'), counts)
+ .length === 0);
+
+ // The unenforced PROSE figure: value free, sentence required.
+ t('⭐ a stale unenforced prose number is NOT a finding',
+ check(page.replace(`Across ${census.declaredObjects} declared objects`, 'Across 4 declared objects'))
+ .length === 0,
+ check(page.replace(`Across ${census.declaredObjects} declared objects`, 'Across 4 declared objects')).join(' | '));
+ t('an unenforced prose claim reworded off the page IS a finding',
+ check(page.replace(`Across ${census.declaredObjects} declared objects`, 'Across the declared objects'))
+ .some((p) => p.startsWith('[unenforced-prose-missing]')));
+
+ // ── refusals ───────────────────────────────────────────────────────────────
+ t('an empty census refuses rather than certifying the artefacts',
+ checkPage({ ...census, sites: [] }, page, counts).some((p) => p.startsWith('[empty-census]')));
+
+ const failed = cases.filter((c) => !c.ok);
+ for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`);
+ if (failed.length > 0) {
+ console.error(`✗ check-tenant-audit-census self-test: ${failed.length} of ${cases.length} case(s) failed.`);
+ return 1;
+ }
+
+ // ⭐ The GENERATOR's classifier cases run here, and this is the only place
+ // they run. They cannot be a flag on that module: CI invoking it directly
+ // makes it a gate file, and `dispatch-gates` then refuses to follow it, which
+ // silently cuts the 22 path literals this gate inherits from it. Running them
+ // from here keeps the instrument AND the inheritance. ⛔ Never let this
+ // swallow the exit code -- a green gate self-test over a red census self-test
+ // is exactly the shape both of them exist to refuse.
+ const censusExit = censusSelfTest();
+ if (censusExit !== 0) {
+ console.error('✗ check-tenant-audit-census self-test: the census self-test it drives FAILED (above).');
+ return censusExit;
+ }
+ console.log(
+ `✓ check-tenant-audit-census self-test: ${cases.length} cases pass `
+ + '(drift on a dropped ledger row, on an edited page count, on an edited ledger '
+ + 'total and on a lost region; prose drift, prose reworded out of reach, and an '
+ + 'empty census -- plus the split in BOTH directions: a corpus-scale count and a '
+ + 'stale measurement date pass, while the population figure beside them, a deleted '
+ + 'unenforced row, an undated block and a reworded unenforced claim all fail).',
+ );
+ return 0;
+}
+
+function main(argv) {
+ if (argv.includes('--self-test')) return selfTest();
+
+ let page;
+ let counts;
+ try {
+ page = readFileSync(join(ROOT, PAGE), 'utf8');
+ } catch (error) {
+ console.error(`::error::[unreadable-page] cannot read ${PAGE} -- ${error.message}`);
+ return 1;
+ }
+ try {
+ counts = readFileSync(join(ROOT, COUNTS), 'utf8');
+ } catch (error) {
+ console.error(`::error::[unreadable-ledger] cannot read ${COUNTS} -- ${error.message}`);
+ return 1;
+ }
+
+ const census = runCensus();
+ const problems = checkPage(census, page, counts);
+ for (const p of problems) console.error(`::error::${p}`);
+
+ if (problems.length > 0) {
+ console.error(`✗ check-tenant-audit-census: ${problems.length} problem(s).`);
+ return 1;
+ }
+ const t = census.totals;
+ console.log(
+ `✓ check-tenant-audit-census: OK -- ${t.writeCallSites} write call sites certified `
+ + `(${t.staticallyDecidableObjectName} decidable; ${t.tenancyEnabledProvablyNoContext} tenancy-enabled `
+ + `sites PROVABLY carry no tenant context, ${t.tenancyEnabledContextUnreadable} more unreadable), `
+ + `${PROSE_COUNTS.length} prose figures held to the census.`,
+ );
+ return 0;
+}
+
+if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));
diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs
new file mode 100644
index 0000000000..806eb8c5ae
--- /dev/null
+++ b/scripts/tenant-audit-census.mjs
@@ -0,0 +1,1407 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * tenant-audit-census -- the committed enumeration of every APPLICATION-SURFACE
+ * write call site against a tenancy-enabled object.
+ *
+ * node scripts/tenant-audit-census.mjs # human summary
+ * node scripts/tenant-audit-census.mjs --json # the whole census, machine-readable
+ * node scripts/tenant-audit-census.mjs --write # rewrite both committed artefacts
+ *
+ * ⚠️ This module deliberately exposes NO `--self-test` flag of its own, and that
+ * is a wiring decision rather than an omission. {@link selfTest} below is real
+ * and is run on every CI pass -- `check-tenant-audit-census.mjs --self-test`
+ * calls it. Giving it a flag would mean CI invoking this file directly, which
+ * makes it a GATE FILE, and `scripts/pm/dispatch-gates.mjs` refuses to follow a
+ * gate file: the 22 path literals this module spells would stop being inherited
+ * by the gate that imports it, so a PR touching `packages/services/**` would
+ * silently stop being told this gate reads its diff. Measured, by that tool's
+ * own self-test, the first time this was wired the other way.
+ *
+ * `content/docs/permissions/tenant-audit-census.mdx` is the page this builds.
+ * `check-tenant-audit-census.mjs` is the gate that holds the page to what this
+ * reports. Together they are the `isSystem` census triple's shape applied to the
+ * tenant-audit control (`SqlDriver.auditMissingTenant`).
+ *
+ * ## ⭐ Why this exists AS AN ARTEFACT, which is the whole point
+ *
+ * The measurement this replaces lived in a COMMENT on issue #13178. That issue
+ * became unreachable -- it 404s on unauthenticated REST, on the rendered page and
+ * on authenticated MCP alike, while its neighbours answer 200 -- and took the
+ * census with it. Three open cards named it as their input. What survived did so
+ * by luck: a changeset author had quoted two of the figures in prose
+ * (`.changeset/tenant-audit-update-delete-half-repairs.md`), so "175 write call
+ * sites, 24 of them carrying no tenant context" is still readable on `main` while
+ * the list of 24 is not recoverable at all.
+ *
+ * ⇒ A census that decides a repair family's severity and a ruling's scope is not
+ * a comment. It is a re-runnable instrument plus a committed page, so that
+ * losing any issue costs nothing, and so the population can be RE-DERIVED
+ * rather than quoted.
+ *
+ * ## What the tenant-audit control actually is
+ *
+ * `SqlDriver.auditMissingTenant(object, op, options)` warns when a write targets
+ * a tenancy-enabled object without `options.tenantId`. It is gated, in order, by
+ * `OS_TENANT_AUDIT=0`, then `options.bypassTenantAudit`, then a present
+ * `tenantId`, then the deployment posture, then the object having a tenant field.
+ *
+ * The engine sets `bypassTenantAudit` for every `ExecutionContext.isSystem` write
+ * (ObjectQL's `buildDriverOptions`), and `options.tenantId` from
+ * `execCtx.tenantId`. So what a CALL SITE controls is one thing: whether it
+ * threads an execution context at all. That is what this census measures.
+ *
+ * ## The population, and the two ways a count goes wrong
+ *
+ * A site is a call to one of the three `IDataEngine` write doors -- `insert`,
+ * `update`, `delete` -- on a receiver whose declared type is an engine, in
+ * tracked non-test sources under `packages/services/` and `packages/plugins/`.
+ *
+ * ⛔ The identifier is not the signal. `.delete()` alone answers ~250 sites in
+ * this corpus, and the overwhelming majority of them are `Map.delete`,
+ * `Set.delete`, `Headers.delete`, a crypto `Hash.update`, an HTTP route
+ * registration, a blob-storage delete-by-key, and a search-index de-index. A
+ * census keyed on the verb name over-reports by more than it reports.
+ *
+ * So the receiver is TYPED, structurally: a corpus-declared interface or type
+ * literal counts as an engine when it declares `insert` / `update` / `delete`
+ * with a first parameter named `object` / `objectName` / `objectApiName` /
+ * `name` and typed `string` -- the `IDataEngine` door signature. Interfaces that
+ * EXTEND one (`IObjectQLEngine extends IDataEngine`) inherit it, and aliases that
+ * NARROW one (`Partial>`) carry it. That found 56
+ * engine-shaped types where a name list would have found the handful someone
+ * remembered.
+ *
+ * ⭐ The second failure direction is the expensive one, and it is a KEYWORD.
+ * Sites whose receiver the author typed `any` -- `ql: any`, `engine: any`,
+ * `(engine as any)` -- have no type to read. There are 45 of them, better than a
+ * fifth of the population, and they are concentrated in exactly the seed and
+ * bootstrap paths this control exists for. Scoring an unreadable receiver as
+ * "not an engine" would have dropped every one of them silently, with a clean
+ * exit and a smaller number that reads exactly like a smaller truth.
+ *
+ * ⇒ `any` is NOT a classification here. It goes to the unreadable pile, and the
+ * unreadable pile is placed by facts about the tree rather than about the
+ * receiver:
+ *
+ * 1. the first argument is a string that NAMES A DECLARED OBJECT, or
+ * 2. the first argument is a parameter declared `object: string` -- the same
+ * door signature the type index keys on, read at the argument instead of
+ * at the receiver, or
+ * 3. an `UNTYPED_RECEIVERS` row says what the receiver is.
+ *
+ * An unreadable receiver that none of the three place is an ERROR, never a
+ * default. That is the direction this census cannot survive being wrong in.
+ *
+ * ## Tenancy is enabled BY DEFAULT, so the registry only finds the opt-outs
+ *
+ * `isTenancyDisabled()` reads `tenancy.enabled === false` and nothing else, so an
+ * object is tenancy-enabled unless it says otherwise. {@link declaredObjects}
+ * walks every `*.object.ts` in the tree; of those, exactly two (`sys_api_key`,
+ * `sys_sso_provider`) opt out. ⛔ The object COUNT is corpus scale and is not
+ * quoted here -- it is emitted, dated and unenforced in the artefacts' own
+ * corpus-scale block, and a number repeated into a comment is a number that
+ * rots where nothing can see it.
+ *
+ * ## What is DECIDABLE, and why that is reported rather than smoothed over
+ *
+ * A site whose object name is an inline literal or a local `const` string is
+ * statically decidable. A site whose name is a parameter or a field
+ * (`objectName`, `this.objectName`) is not, and no amount of AST work makes it
+ * so -- the object is chosen at run time. Those are reported as `undecidable`
+ * rather than assumed either way, because a census that quietly guesses on 30%
+ * of its own population is the "73% coverage that reads like full coverage"
+ * failure the class-level control was warned about.
+ *
+ * ## ⭐ Two kinds of number, rendered apart
+ *
+ * Both artefacts carry the POPULATION this census certifies (`census.totals`:
+ * every write call site and its tenancy/context verdict) and, separately, the
+ * CORPUS SCALE it walked ({@link corpusScaleRows}: sources read, engine-shaped
+ * types recognised, objects declared, same-named calls subtracted). The gate
+ * enforces the first and deliberately does not enforce the second -- a
+ * maintainer ruling of 2026-08-31 adopting the split the sibling `isSystem`
+ * census had already proved.
+ *
+ * ⇒ That is why the scale numbers are rendered in their own DATED block rather
+ * than mixed in among the totals. An artefact whose unenforced numbers sit
+ * inside its enforced ones cannot tell a reader which is which, and the reader
+ * is the person the split is FOR. {@link renderCorpusScale} emits the block,
+ * {@link measuredAt} dates it, and `check-tenant-audit-census.mjs` carries the
+ * reasoning and the measurement that draws the line where it is drawn.
+ *
+ * ## Refusals, never quiet passes (#4690)
+ *
+ * A corpus of zero sources, an object registry of zero declarations, a source
+ * that cannot be read, a source that does not parse (`ts-parse.mjs` refuses), an
+ * unreadable receiver with no placement, and a ledger row that matches nothing
+ * are all non-zero exits naming what could not be read.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import ts from 'typescript';
+
+import { isEntrypoint } from './invoked-as.mjs';
+import { parseSourceFile } from './ts-parse.mjs';
+
+export const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+export const WRITE_VERBS = ['insert', 'update', 'delete'];
+export const OBJECT_PARAM_NAMES = new Set(['object', 'objectName', 'objectApiName', 'name']);
+export const SURFACE_ROOTS = ['packages/services', 'packages/plugins'];
+
+export function isTestPath(relPath) {
+ return /\.(test|spec)\./.test(relPath) || /(^|\/)(tests|__tests__|qa)\//.test(relPath);
+}
+
+export function trackedTs(root, roots) {
+ return execFileSync('git', ['-C', root, 'ls-files', ...roots], { encoding: 'utf8', maxBuffer: 1 << 28 })
+ .split('\n')
+ .filter(Boolean)
+ .filter((f) => /\.(ts|tsx|mts|cts)$/.test(f) && !f.includes('/dist/'));
+}
+
+export function collectSources(root = ROOT, roots = SURFACE_ROOTS) {
+ const out = trackedTs(root, roots).filter((f) => !isTestPath(f));
+ if (out.length === 0) throw new Error('tenant-audit-census: corpus resolved to ZERO source files');
+ return out;
+}
+
+/**
+ * When the corpus-scale numbers were true, and against which tree.
+ *
+ * ⛔ Read at CENSUS time, not at check time, and deliberately NOT compared by the
+ * gate -- requiring it to be RECENT would re-introduce exactly the churn the
+ * enforced/unenforced split removes. Its job is to tell a reader how old the
+ * unenforced numbers are, not to be fresh.
+ *
+ * A tree whose HEAD cannot be read REFUSES rather than emitting a marker that
+ * reads as a measurement and is not one (#4690).
+ */
+export function measuredAt(root = ROOT) {
+ let ref;
+ try {
+ ref = execFileSync('git', ['-C', root, 'rev-parse', '--short=9', 'HEAD'], { encoding: 'utf8' }).trim();
+ } catch (error) {
+ throw new Error(`tenant-audit-census: cannot read HEAD to date the corpus-scale numbers -- ${error.message}`);
+ }
+ if (!/^[0-9a-f]{7,40}$/.test(ref)) {
+ throw new Error(`tenant-audit-census: HEAD did not resolve to a sha -- got ${JSON.stringify(ref)}`);
+ }
+ return { date: new Date().toISOString().slice(0, 10), ref };
+}
+
+/** Does this member declaration look like an ObjectQL data-engine door? */
+function memberIsEngineDoor(member, sf) {
+ const nm = member.name && ts.isIdentifier(member.name) ? member.name.text : null;
+ if (!nm) return false;
+ const params = member.parameters ?? member.type?.parameters;
+ if (!params || params.length === 0) return false;
+ const p0 = params[0];
+ if (!p0.name || !ts.isIdentifier(p0.name)) return false;
+ if (!OBJECT_PARAM_NAMES.has(p0.name.text)) return false;
+ const t = p0.type ? p0.type.getText(sf).replace(/\s+/g, '') : null;
+ if (t !== 'string') return false;
+ return { name: nm, isWrite: WRITE_VERBS.includes(nm) };
+}
+
+/** Every corpus-declared type whose shape is an ObjectQL data engine. */
+export function buildEngineTypeIndex(root = ROOT) {
+ const index = new Map(); // type name -> { decls, verbs }
+ const files = trackedTs(root, ['packages', 'examples']);
+ for (const rel of files) {
+ const text = readFileSync(join(root, rel), 'utf8');
+ if (!/\b(insert|update|delete)\??\s*[(<]/.test(text)) continue;
+ if (!/\b(object|objectName|objectApiName)\s*:\s*string/.test(text)) continue;
+ const sf = parseSourceFile(rel, text);
+ const visit = (node) => {
+ let name = null;
+ let members = null;
+ if (ts.isInterfaceDeclaration(node)) {
+ name = node.name.text;
+ members = node.members;
+ } else if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type)) {
+ name = node.name.text;
+ members = node.type.members;
+ }
+ if (name && members) {
+ const verbs = [];
+ let doors = 0;
+ for (const m of members) {
+ if (!ts.isMethodSignature(m) && !ts.isPropertySignature(m)) continue;
+ const hit = memberIsEngineDoor(m, sf);
+ if (!hit) continue;
+ doors += 1;
+ if (hit.isWrite) verbs.push(hit.name);
+ }
+ if (verbs.length > 0) {
+ const prev = index.get(name);
+ if (prev) prev.decls.push(rel);
+ else index.set(name, { decls: [rel], verbs, doors });
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ }
+ // An interface that EXTENDS an engine-shaped interface inherits its doors
+ // (`IObjectQLEngine extends IDataEngine`). Collected in its OWN sweep because
+ // the derived declaration need not restate a single door, so the shape
+ // prefilter above cannot see it -- and a receiver spelled with the derived
+ // name is exactly the site a census must not lose.
+ for (let pass = 0; pass < 8; pass += 1) {
+ let changed = false;
+ for (const rel of files) {
+ const text = readFileSync(join(root, rel), 'utf8');
+ if (!/\bextends\b/.test(text)) continue;
+ let mentions = false;
+ for (const known of index.keys()) if (text.includes(known)) { mentions = true; break; }
+ if (!mentions) continue;
+ const sf = parseSourceFile(rel, text);
+ const visit = (node) => {
+ if (ts.isInterfaceDeclaration(node) && !index.has(node.name.text)) {
+ const bases = (node.heritageClauses ?? []).flatMap((h) => h.types
+ .filter((t) => ts.isIdentifier(t.expression)).map((t) => t.expression.text));
+ const engineBases = bases.filter((b) => index.has(b));
+ if (engineBases.length > 0) {
+ index.set(node.name.text, {
+ decls: [rel],
+ verbs: [...new Set(engineBases.flatMap((b) => index.get(b).verbs))],
+ via: engineBases,
+ });
+ changed = true;
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ }
+ if (!changed) break;
+ }
+ return index;
+}
+
+
+/** Type-reference names appearing anywhere inside a type node. */
+function typeRefNames(node, sf) {
+ const names = [];
+ const walk = (n) => {
+ if (ts.isTypeReferenceNode(n) && ts.isIdentifier(n.typeName)) names.push(n.typeName.text);
+ ts.forEachChild(n, walk);
+ };
+ walk(node);
+ return names;
+}
+
+/**
+ * Aliases that NARROW an engine-shaped type -- `Partial>`
+ * and friends. Strict on purpose: every type reference in the alias must be
+ * either an engine-shaped type or one of the mapped-type wrappers below, so an
+ * alias that merely MENTIONS an engine type in some unrelated position is not
+ * swept in. One pass, no transitive closure.
+ */
+const NARROWING_WRAPPERS = new Set(['Pick', 'Partial', 'Omit', 'Readonly', 'Required', 'NonNullable']);
+
+export function widenIndexThroughAliases(index, root = ROOT) {
+ const added = new Map();
+ for (const rel of trackedTs(root, ['packages', 'examples'])) {
+ const text = readFileSync(join(root, rel), 'utf8');
+ if (!/\btype\s+\w+\s*=/.test(text)) continue;
+ let mentions = false;
+ for (const known of index.keys()) if (text.includes(known)) { mentions = true; break; }
+ if (!mentions) continue;
+ const sf = parseSourceFile(rel, text);
+ const visit = (node) => {
+ if (ts.isTypeAliasDeclaration(node) && !ts.isTypeLiteralNode(node.type) && !index.has(node.name.text)) {
+ const refs = typeRefNames(node.type, sf);
+ const engineRefs = refs.filter((r) => index.has(r));
+ const strayRefs = refs.filter((r) => !index.has(r) && !NARROWING_WRAPPERS.has(r));
+ if (engineRefs.length > 0 && strayRefs.length === 0) {
+ const picked = (node.type.getText(sf).match(/'(insert|update|delete)'/g) ?? []).map((q) => q.slice(1, -1));
+ const verbs = picked.length > 0
+ ? [...new Set(picked)]
+ : [...new Set(engineRefs.flatMap((r) => index.get(r).verbs))];
+ if (verbs.length > 0) added.set(node.name.text, { decls: [rel], verbs, via: engineRefs });
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ }
+ for (const [k, v] of added) index.set(k, v);
+ return index;
+}
+
+/** Unwrap `x!`, `(x)`, `x as T`, `x` down to the receiver expression. */
+function unwrap(n) {
+ if (ts.isNonNullExpression(n) || ts.isParenthesizedExpression(n)) return unwrap(n.expression);
+ if (ts.isAwaitExpression(n)) return unwrap(n.expression);
+ if (ts.isAsExpression(n) || ts.isTypeAssertionExpression?.(n)) return n;
+ return n;
+}
+
+/** Declared types visible in ONE file, keyed the way a receiver spells itself. */
+export function declaredTypesIn(sf) {
+ const thisProps = new Map();
+ const locals = new Map();
+ const fnReturns = new Map();
+ // `TypeName -> member -> declared type` for shapes declared in THIS file, so
+ // `deps.getDataEngine()` and `opts.engine` resolve without a type checker.
+ const shapes = new Map();
+ // Identifiers imported from a `node:` builtin -- never an engine.
+ const builtins = new Set();
+ const note = (map, key, typeNode, initializer) => {
+ if (map.has(key) && map.get(key).type) return;
+ map.set(key, {
+ type: typeNode ? typeNode.getText(sf).replace(/\s+/g, ' ') : null,
+ init: initializer ? initializer.getText(sf).replace(/\s+/g, ' ').slice(0, 120) : null,
+ node: initializer ?? null,
+ literal: initializer && ts.isStringLiteralLike(initializer) ? initializer.text : null,
+ });
+ };
+ const visit = (n) => {
+ if (ts.isImportDeclaration(n) && ts.isStringLiteralLike(n.moduleSpecifier)
+ && /^node:/.test(n.moduleSpecifier.text)) {
+ const b = n.importClause?.namedBindings;
+ if (b && ts.isNamedImports(b)) for (const el of b.elements) builtins.add(el.name.text);
+ if (n.importClause?.name) builtins.add(n.importClause.name.text);
+ }
+ if (ts.isInterfaceDeclaration(n) || (ts.isTypeAliasDeclaration(n) && ts.isTypeLiteralNode(n.type))) {
+ const members = ts.isInterfaceDeclaration(n) ? n.members : n.type.members;
+ const m = new Map();
+ for (const mem of members) {
+ if (!mem.name || !ts.isIdentifier(mem.name)) continue;
+ const t = ts.isMethodSignature(mem) ? mem.type : mem.type;
+ if (t) m.set(mem.name.text, t.getText(sf).replace(/\s+/g, ' '));
+ }
+ shapes.set(n.name.text, m);
+ }
+ if (ts.isPropertyDeclaration(n) && ts.isIdentifier(n.name)) note(thisProps, n.name.text, n.type, n.initializer);
+ if (ts.isParameter(n) && ts.isIdentifier(n.name)) {
+ if (ts.isConstructorDeclaration(n.parent) && n.modifiers?.length) note(thisProps, n.name.text, n.type, n.initializer);
+ note(locals, n.name.text, n.type, n.initializer);
+ }
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name)) {
+ // `const getData = (): IDataEngine | undefined => …` -- the RETURN type is
+ // what a caller of `getData()` receives, not what `getData` itself is.
+ const init = n.initializer;
+ if (!n.type && init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) && init.type) {
+ note(fnReturns, n.name.text, init.type, null);
+ }
+ note(locals, n.name.text, n.type, n.initializer);
+ }
+ // `const { engine, cryptoProvider } = deps;` -- the member's declared type on
+ // the base's own shape. Losing these loses REAL engine sites, which is the
+ // one direction a census must never fail in.
+ if (ts.isVariableDeclaration(n) && ts.isObjectBindingPattern(n.name)) {
+ const baseText = n.type ? n.type.getText(sf)
+ : (n.initializer && ts.isIdentifier(n.initializer) ? locals.get(n.initializer.text)?.type : null);
+ for (const el of n.name.elements) {
+ if (!ts.isIdentifier(el.name)) continue;
+ const prop = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
+ const mt = memberTypeOfShapes(baseText, prop, shapes);
+ if (mt) locals.set(el.name.text, { type: mt, init: null, node: null });
+ else if (n.initializer && ts.isAwaitExpression(n.initializer)
+ && ts.isCallExpression(n.initializer.expression)
+ && n.initializer.expression.expression.kind === ts.SyntaxKind.ImportKeyword
+ && ts.isStringLiteralLike(n.initializer.expression.arguments[0])
+ && /^node:/.test(n.initializer.expression.arguments[0].text)) {
+ builtins.add(el.name.text);
+ }
+ }
+ }
+ if ((ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)) && n.name && ts.isIdentifier(n.name)) {
+ note(fnReturns, n.name.text, n.type, null);
+ }
+ ts.forEachChild(n, visit);
+ };
+ visit(sf);
+ return { thisProps, locals, fnReturns, shapes, builtins };
+}
+
+/** The declared type of `.` when `base`'s shape is in this file. */
+function memberTypeOfShapes(baseTypeText, member, shapes) {
+ if (!baseTypeText) return null;
+ for (const id of baseTypeText.match(/[A-Za-z_$][\w$]*/g) ?? []) {
+ const shape = shapes.get(id);
+ if (shape?.has(member)) return shape.get(member);
+ }
+ return null;
+}
+
+function memberTypeOf(baseTypeText, member, decls) {
+ return memberTypeOfShapes(baseTypeText, member, decls.shapes);
+}
+
+/** How a receiver spells itself, for ledger keys and diagnostics. */
+export function receiverKey(node, sf) {
+ return node.getText(sf).replace(/\s+/g, ' ');
+}
+
+/** Resolve a receiver expression to an engine-shaped type name, or a reason it is not one. */
+export function resolveReceiver(recvNode, sf, decls, index, depth = 0) {
+ const r = unwrap(recvNode);
+ if (depth > 4) return { kind: 'unresolved', how: 'depth' };
+ const nameOf = (typeText) => {
+ if (!typeText) return null;
+ for (const id of typeText.match(/[A-Za-z_$][\w$]*/g) ?? []) if (index.has(id)) return id;
+ return null;
+ };
+ const fromEntry = (entry, how) => {
+ if (!entry) return { kind: 'unresolved', how };
+ const t = nameOf(entry.type);
+ if (t) return { kind: 'engine', type: t, how };
+ // ⛔ `any` / `unknown` is NOT a classification. A receiver the author erased
+ // is a receiver this census could not read, and a census must never score
+ // what it could not read as "nothing to report" -- it goes to the ledger.
+ if (entry.type && /^(any|unknown)$/.test(entry.type.trim())) {
+ return { kind: 'unresolved', how: `${how}:any`, detail: entry.type };
+ }
+ if (entry.type) return { kind: 'other', type: entry.type, how };
+ if (entry.init) {
+ const t2 = nameOf(entry.init);
+ if (t2) return { kind: 'engine', type: t2, how: `${how}/init` };
+ const ctor = /^new\s+([A-Za-z_$][\w$.]*)/.exec(entry.init);
+ if (ctor) {
+ const t3 = nameOf(ctor[1]);
+ if (t3) return { kind: 'engine', type: t3, how: `${how}/new` };
+ return { kind: 'other', type: `new ${ctor[1]}`, how: `${how}/new` };
+ }
+ if (entry.node) {
+ const via = resolveReceiver(entry.node, sf, decls, index, depth + 1);
+ if (via.kind !== 'unresolved') return { ...via, how: `${how}/${via.how}` };
+ }
+ return { kind: 'unresolved', how: `${how}/init`, detail: entry.init };
+ }
+ return { kind: 'unresolved', how };
+ };
+ if (ts.isAsExpression(r)) {
+ const t = nameOf(r.type.getText(sf));
+ if (t) return { kind: 'engine', type: t, how: 'as' };
+ // `as any` / `as unknown` erase nothing about the VALUE -- keep walking the
+ // operand, or a cast would hide a real engine receiver from the census.
+ const erasing = /^(any|unknown)$/.test(r.type.getText(sf).trim());
+ if (erasing) {
+ const via = resolveReceiver(r.expression, sf, decls, index, depth + 1);
+ if (via.kind !== 'unresolved') return { ...via, how: `as-any/${via.how}` };
+ return { kind: 'unresolved', how: 'as-any', detail: receiverKey(r.expression, sf) };
+ }
+ return { kind: 'other', type: r.type.getText(sf), how: 'as' };
+ }
+ if (ts.isPropertyAccessExpression(r) && r.expression.kind === ts.SyntaxKind.ThisKeyword) {
+ return fromEntry(decls.thisProps.get(r.name.text), `this.${r.name.text}`);
+ }
+ if (ts.isIdentifier(r)) {
+ if (decls.builtins.has(r.text)) return { kind: 'other', type: `node: builtin ${r.text}`, how: 'node-import' };
+ return fromEntry(decls.locals.get(r.text), r.text);
+ }
+ // `opts.engine`, `this.options.persistence` -- resolved through the shape the
+ // base's own declared type gives the member.
+ if (ts.isPropertyAccessExpression(r)) {
+ const baseText = ts.isPropertyAccessExpression(r.expression) && r.expression.expression.kind === ts.SyntaxKind.ThisKeyword
+ ? decls.thisProps.get(r.expression.name.text)?.type
+ : ts.isIdentifier(r.expression) ? decls.locals.get(r.expression.text)?.type : null;
+ const mt = memberTypeOf(baseText, r.name.text, decls);
+ if (mt) {
+ const t = nameOf(mt);
+ if (t) return { kind: 'engine', type: t, how: `member ${r.name.text}` };
+ return { kind: 'other', type: mt, how: `member ${r.name.text}` };
+ }
+ }
+ if (ts.isCallExpression(r)) {
+ const callee = r.expression;
+ const fname = ts.isIdentifier(callee) ? callee.text
+ : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
+ const generic = r.typeArguments?.length ? nameOf(r.typeArguments[0].getText(sf)) : null;
+ if (generic) return { kind: 'engine', type: generic, how: `${fname}<>` };
+ if (ts.isIdentifier(callee) && decls.builtins.has(callee.text)) {
+ return { kind: 'other', type: `node: builtin ${callee.text}()`, how: 'node-import' };
+ }
+ // `deps.getDataEngine()` / `service.getAdapter(...)` -- the member's declared
+ // RETURN type, read off the base's own shape.
+ if (ts.isPropertyAccessExpression(callee)) {
+ const baseText = callee.expression.kind === ts.SyntaxKind.ThisKeyword
+ ? null
+ : ts.isIdentifier(callee.expression) ? decls.locals.get(callee.expression.text)?.type : null;
+ const mt = memberTypeOf(baseText, callee.name.text, decls);
+ if (mt) {
+ const t = nameOf(mt);
+ if (t) return { kind: 'engine', type: t, how: `${fname}() return` };
+ return { kind: 'other', type: mt, how: `${fname}() return` };
+ }
+ }
+ const entry = fname ? decls.fnReturns.get(fname) : null;
+ if (entry) return fromEntry(entry, `${fname}()`);
+ return { kind: 'unresolved', how: 'call', detail: receiverKey(r, sf) };
+ }
+ if (r.kind === ts.SyntaxKind.ThisKeyword) return { kind: 'unresolved', how: 'this' };
+ return { kind: 'unresolved', how: ts.SyntaxKind[r.kind], detail: receiverKey(r, sf) };
+}
+
+/**
+ * Every object the tree DECLARES, with its tenancy posture.
+ *
+ * Tenancy is enabled by DEFAULT: `isTenancyDisabled()` reads
+ * `tenancy.enabled === false` and nothing else, so the registry only has to
+ * find the objects that opt OUT. Two do, today.
+ *
+ * The name set doubles as the census's discriminator for `any`-typed receivers
+ * -- see {@link runCensus}.
+ */
+export function declaredObjects(root = ROOT) {
+ const objects = new Map();
+ for (const rel of trackedTs(root, ['packages', 'examples'])) {
+ if (!/\.object\.tsx?$/.test(rel)) continue;
+ const text = readFileSync(join(root, rel), 'utf8');
+ const sf = parseSourceFile(rel, text);
+ const visit = (n) => {
+ if (ts.isObjectLiteralExpression(n)) {
+ let nm = null;
+ let disabled = false;
+ for (const prop of n.properties) {
+ if (!ts.isPropertyAssignment(prop) || !prop.name) continue;
+ const key = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : null;
+ if (key === 'name' && ts.isStringLiteralLike(prop.initializer)) nm = prop.initializer.text;
+ if (key === 'tenancy' && ts.isObjectLiteralExpression(prop.initializer)) {
+ for (const q of prop.initializer.properties) {
+ if (ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'enabled'
+ && q.initializer.kind === ts.SyntaxKind.FalseKeyword) disabled = true;
+ }
+ }
+ }
+ if (nm && /^[a-z][a-z0-9_]*$/.test(nm) && !objects.has(nm)) objects.set(nm, { file: rel, tenancyDisabled: disabled });
+ else if (nm && disabled) objects.set(nm, { file: rel, tenancyDisabled: true });
+ }
+ ts.forEachChild(n, visit);
+ };
+ visit(sf);
+ }
+ if (objects.size === 0) {
+ throw new Error(
+ 'tenant-audit-census: the object registry resolved to ZERO declarations -- refusing to '
+ + 'classify tenancy against nothing (a walk that found no objects and a tree with no '
+ + 'objects are different).',
+ );
+ }
+ return objects;
+}
+
+/**
+ * What execution context, if any, this write call threads -- and whether that
+ * context is ELEVATED.
+ *
+ * ## ⛔ Three answers, because "I could not read it" is not "there is none"
+ *
+ * `carries` is `true` / `false` / `'undecidable'`, and the third value is
+ * load-bearing. The first edition had two, and folded an unreadable options
+ * argument -- `engine.update(object, data, options)` inside a forwarding shim,
+ * `{ ...opts }`, a variable -- into `false`. That published **84 sites
+ * "carrying NO tenant context at all"** when only 17 of them said so; the other
+ * 67 were arguments the walker could not read.
+ *
+ * ⭐ That is an over-claim in the ALARMING direction, on the one figure this page
+ * tells other cards to cite. It is the same failure this whole artefact exists
+ * to stop, wearing the opposite hat: not a population under-counted into
+ * silence, but an unknown published as a finding. A number that cannot tell
+ * "provably unscoped" from "unread" is not evidence of anything.
+ *
+ * So:
+ * - `false` -- the options argument was READ and holds no context: an
+ * object literal with no `context` / `tenantId` key, or no
+ * options argument at all. This is the control's real
+ * yield surface.
+ * - `'undecidable'` -- an options argument this cannot read. It may carry a
+ * context; a static reading cannot say.
+ * - `true` -- a `context` or `tenantId` key is there.
+ *
+ * A spread inside an otherwise readable literal makes the answer undecidable for
+ * the same reason it does in {@link elevationOf}: the spread may carry the key
+ * the literal never names.
+ */
+export function tenantContextOf(node, sf, decls) {
+ const args = node.arguments.slice(1);
+ if (args.length === 0) return { carries: false, how: 'no-options-argument', system: false };
+ let opaque = null;
+ let spread = null;
+ for (const a of args) {
+ if (ts.isObjectLiteralExpression(a)) {
+ for (const prop of a.properties) {
+ if (ts.isSpreadAssignment(prop)) { spread = prop.expression.getText(sf).replace(/\s+/g, ' ').slice(0, 40); continue; }
+ const key = prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)) ? prop.name.text : null;
+ if (key === 'context') {
+ const value = ts.isPropertyAssignment(prop) ? prop.initializer : null;
+ return { carries: true, how: 'options.context', system: elevationOf(value, sf, decls) };
+ }
+ if (key === 'tenantId') return { carries: true, how: 'options.tenantId', system: false };
+ }
+ } else if (!ts.isStringLiteralLike(a) && !ts.isNumericLiteral(a)
+ && a.kind !== ts.SyntaxKind.TrueKeyword && a.kind !== ts.SyntaxKind.FalseKeyword) {
+ opaque = a.getText(sf).replace(/\s+/g, ' ').slice(0, 60);
+ }
+ }
+ if (opaque) return { carries: 'undecidable', how: 'options-argument-unreadable', opaque, system: 'undecidable' };
+ if (spread) return { carries: 'undecidable', how: 'options-spread-unreadable', opaque: spread, system: 'undecidable' };
+ return { carries: false, how: 'no-context-key', system: false };
+}
+
+/**
+ * Is this context expression an ELEVATED (`isSystem: true`) one?
+ *
+ * ## ⛔ A SPREAD is not evidence of absence
+ *
+ * The first edition of this walked an object literal's named properties looking
+ * for `isSystem`, skipped anything that was not a `PropertyAssignment`, and
+ * returned `false` when the loop ended. A `SpreadAssignment` carries no `name`,
+ * so `{ ...SYSTEM_CTX }` fell through every branch and was reported as
+ * **decidably NOT elevated** -- the exact opposite of the truth, since every
+ * `SYSTEM_CTX` in the tree is `{ isSystem: true, … }`.
+ *
+ * That is the worst direction a classifier can fail in, and it is this repo's
+ * recurring shape: a thing the walker could not read, scored as a thing with
+ * nothing to report. It was measured, not reasoned about -- six sites across
+ * `service-storage` were published as "decidably not elevated" while all six
+ * spread an elevated context.
+ *
+ * So a spread is resolved, and an unresolvable one makes the whole answer
+ * `undecidable`. It can never contribute `false`.
+ *
+ * `as const` is unwrapped on the way: the constants this has to read are
+ * declared `{ isSystem: true } as const`, which is an `AsExpression` wrapping
+ * the literal, not a literal.
+ */
+function elevationOf(value, sf, decls, depth = 0) {
+ if (value == null || depth > 4) return 'undecidable';
+
+ /** `{ … } as const` / `({ … })` down to the literal. */
+ const unwrapLiteral = (n) => {
+ if (!n) return null;
+ if (ts.isAsExpression(n) || ts.isParenthesizedExpression(n)) return unwrapLiteral(n.expression);
+ return n;
+ };
+
+ /** The object literal an expression resolves to in this file, or null. */
+ const literalFor = (n) => {
+ const bare = unwrapLiteral(n);
+ if (!bare) return null;
+ if (ts.isObjectLiteralExpression(bare)) return bare;
+ if (ts.isIdentifier(bare)) return unwrapLiteral(decls?.locals.get(bare.text)?.node) ?? null;
+ return null;
+ };
+
+ const readLiteral = (node, d) => {
+ const lit = literalFor(node);
+ if (!lit || !ts.isObjectLiteralExpression(lit)) return null;
+ let sawUnresolvableSpread = false;
+ // Later properties win in an object literal, so the LAST answer decides.
+ let verdict = false;
+ for (const prop of lit.properties) {
+ if (ts.isSpreadAssignment(prop)) {
+ if (d > 4) { sawUnresolvableSpread = true; continue; }
+ const inner = readLiteral(prop.expression, d + 1);
+ // ⛔ `null` here means "could not read it", NOT "it said no".
+ if (inner === null || inner === 'undecidable') sawUnresolvableSpread = true;
+ else verdict = inner;
+ continue;
+ }
+ if (!ts.isPropertyAssignment(prop) || !prop.name || !ts.isIdentifier(prop.name)) continue;
+ if (prop.name.text !== 'isSystem') continue;
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) verdict = true;
+ else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) verdict = false;
+ else return 'undecidable';
+ }
+ // An unread spread can only be resolved DOWNWARD to uncertainty: it may have
+ // carried the flag this literal never mentions.
+ if (sawUnresolvableSpread && verdict !== true) return 'undecidable';
+ return verdict;
+ };
+
+ const answer = readLiteral(value, depth);
+ return answer === null ? 'undecidable' : answer;
+}
+
+/**
+ * ⛔ SHRINK-ONLY, and keyed by (file, receiver) -- never by line.
+ *
+ * The write calls whose receiver has no readable type AND that neither placement
+ * rule reaches. Each row says what the receiver really is, and `engine` says
+ * whether it is one of ours. A row that matches nothing in the tree FAILS: its
+ * reason has outlived the code it described.
+ *
+ * ⚠️ Deliberately NOT keyed by line. A ledger of line numbers rots exactly like
+ * the page anchors this mechanism exists to stop rotting, and it rots INVISIBLY,
+ * because a stale row still excuses a site.
+ *
+ * ⭐ `engine: true` rows are COUNTED into the census. Eleven of the sites below
+ * are real engine writes reached through an `any`, and eleven is 5% of this
+ * population -- a ledger that could only subtract would be a ledger that can only
+ * shrink the truth.
+ */
+export const UNTYPED_RECEIVERS = [
+ // ── Real engine writes, reached through an erased receiver ──────────────────
+ {
+ file: 'packages/plugins/plugin-auth/src/member-role-canonical.ts',
+ receiver: 'ql',
+ engine: true,
+ what: '`ql: any` seed helper writing `MEMBER_OBJECT` (= `SystemObjectName.MEMBER`, an enum member, so the name is not a readable literal)',
+ },
+ {
+ file: 'packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts',
+ receiver: '(engine as any)',
+ engine: true,
+ what: 'the ObjectQL engine behind an `as any`, writing the declared email-template rows',
+ },
+ {
+ file: 'packages/plugins/plugin-security/src/claim-seed-ownership.ts',
+ receiver: 'ql',
+ engine: true,
+ what: '`ql: any` seed helper writing `schema.name` -- a runtime object name off the registered schema',
+ },
+ {
+ file: 'packages/plugins/plugin-sharing/src/sharing-plugin.ts',
+ receiver: 'engine',
+ engine: true,
+ what: '`engine: any` sharing backfill writing a runtime `object`',
+ },
+ {
+ file: 'packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts',
+ receiver: 'engine',
+ engine: true,
+ what: '`engine: any` bootstrap writing `subscriptionsObject`',
+ },
+ {
+ file: 'packages/services/service-settings/src/settings-service-plugin.ts',
+ receiver: 'eng',
+ engine: true,
+ what: 'the settings service\'s engine facade forwarding to `eng: any`; its own `objectName` parameter carries no annotation to read',
+ },
+
+ // ── Not the data engine. Same three verb names, different mechanism ─────────
+ {
+ file: 'packages/plugins/plugin-auth/src/two-factor-reenrollment-verified-reset.ts',
+ receiver: 'adapter',
+ engine: false,
+ what: 'the better-auth adapter -- `update({ model, update, where })`, a keyword object, not `(object, data, options)`',
+ },
+ {
+ file: 'packages/services/service-automation/src/builtin/map-node.ts',
+ receiver: 'variables',
+ engine: false,
+ what: 'the flow run\'s variable Map -- `delete(`${node.id}.$mapItemDone`)` clears a handoff key',
+ },
+ {
+ file: 'packages/services/service-cluster-redis/src/pubsub.ts',
+ receiver: 'b',
+ engine: false,
+ what: 'a `Set` of subscriber handlers read out of `this.subs`',
+ },
+ {
+ file: 'packages/services/service-cluster/src/memory/pubsub.ts',
+ receiver: 'b',
+ engine: false,
+ what: 'the in-memory sibling of the redis pubsub Set above',
+ },
+ {
+ file: 'packages/services/service-cluster/src/memory/lock.ts',
+ receiver: 'self.holders',
+ engine: false,
+ what: 'the lock\'s holder Map, dropping a released holder',
+ },
+ {
+ file: 'packages/services/service-cluster/src/testing.ts',
+ receiver: 'kv',
+ engine: false,
+ what: 'the cluster KV under the shared conformance suite this module EXPORTS -- `kv.delete(\'k\')` deletes a key, and the file is a suite factory rather than a test by path',
+ },
+ {
+ file: 'packages/services/service-knowledge/src/knowledge-reap-guard.ts',
+ receiver: 'adapter',
+ engine: false,
+ what: 'a knowledge search-index adapter -- `delete([documentId], { source })` de-indexes documents',
+ },
+ {
+ file: 'packages/services/service-messaging/src/memory-http-outbox.ts',
+ receiver: 'this',
+ engine: false,
+ what: 'the outbox class\'s OWN `private insert(...)`, which takes a delivery record and no object name',
+ },
+ {
+ file: 'packages/services/service-messaging/src/sql-http-outbox.ts',
+ receiver: 'this',
+ engine: false,
+ what: 'the SQL outbox\'s own `private insert(...)`, same shape as its in-memory sibling',
+ },
+ {
+ file: 'packages/services/service-realtime/src/in-memory-realtime-adapter.ts',
+ receiver: 'channelSubs',
+ engine: false,
+ what: 'a `Set` of channel subscriptions read out of `this.channelIndex`',
+ },
+ {
+ file: 'packages/services/service-storage/src/attachment-lifecycle.ts',
+ receiver: 'storage',
+ engine: false,
+ what: 'the blob-storage backend -- `delete(row.key)` removes BYTES by storage key, not a row by object name',
+ },
+];
+
+/**
+ * What the FIRST argument of a write call names.
+ *
+ * `literal` and `const-literal` are the statically decidable halves -- a `const`
+ * object name is as decidable as an inline one, and reading it that way is what
+ * keeps the undecidable bucket honest about being genuinely undecidable rather
+ * than merely unread. 37 of this census's sites name their object through a
+ * `const`.
+ */
+export function resolveObjectNameArg(a0, sf, decls) {
+ if (a0 == null) return { kind: 'absent', name: null };
+ if (ts.isStringLiteralLike(a0)) return { kind: 'literal', name: a0.text };
+ if (ts.isIdentifier(a0)) {
+ const entry = decls.locals.get(a0.text);
+ if (entry?.literal) return { kind: 'const-literal', name: entry.literal };
+ if (OBJECT_PARAM_NAMES.has(a0.text) && entry?.type?.trim() === 'string') {
+ return { kind: 'object-name-parameter', name: a0.getText(sf) };
+ }
+ }
+ return { kind: 'runtime', name: a0.getText(sf).replace(/\s+/g, ' ') };
+}
+
+/** Run the census. */
+export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) {
+ const index = widenIndexThroughAliases(buildEngineTypeIndex(root), root);
+ const objects = declaredObjects(root);
+ const sources = collectSources(root, roots);
+ const sites = [];
+ const unresolved = [];
+ const usedRows = new Set();
+ let nonEngineCalls = 0;
+
+ for (const rel of sources) {
+ let text;
+ try {
+ text = readFileSync(join(root, rel), 'utf8');
+ } catch (error) {
+ throw new Error(`tenant-audit-census: cannot read ${rel} -- ${error.message}`);
+ }
+ if (!/\.(insert|update|delete)\s*[(<]/.test(text)) continue;
+ const sf = parseSourceFile(rel, text);
+ const decls = declaredTypesIn(sf);
+ const visit = (node) => {
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
+ && WRITE_VERBS.includes(node.expression.name.text)) {
+ const verb = node.expression.name.text;
+ const res = resolveReceiver(node.expression.expression, sf, decls, index);
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
+ const a0 = node.arguments[0];
+ const arg = resolveObjectNameArg(a0, sf, decls);
+ const decided = arg.kind === 'literal' || arg.kind === 'const-literal';
+ const objectName = arg.name;
+ const where = {
+ file: rel, line: line + 1,
+ receiver: receiverKey(node.expression.expression, sf),
+ };
+
+ let kind = res.kind;
+ let engineType = res.type ?? null;
+ let placedBy = 'declared-type';
+ // ⭐ THE RESCUE. A receiver the author typed `any` carries no type to
+ // read, and 44 of this census's sites are spelled that way. Their write
+ // calls are still placeable, because the FIRST ARGUMENT names a declared
+ // object -- a fact about the tree, not about the receiver's name. Without
+ // this the census silently loses a quarter of its own population to a
+ // keyword.
+ if (kind === 'unresolved' && decided && objects.has(objectName)) {
+ kind = 'engine';
+ engineType = 'untyped receiver, placed by object name';
+ placedBy = 'object-name';
+ } else if (kind === 'unresolved' && arg.kind === 'object-name-parameter') {
+ // The second half of the same rescue. These are the RUNTIME-NAME
+ // sites: `ql.insert(object, …)` inside a `(ql: any, object: string)`
+ // helper. The receiver is erased AND the object is a parameter, so
+ // neither the type nor the name places them -- but the argument is
+ // declared with exactly the door signature the type index keys on
+ // (`object: string` in first position), which is a fact about the
+ // declaration rather than a guess about the identifier.
+ kind = 'engine';
+ engineType = 'untyped receiver, placed by object-name parameter';
+ placedBy = 'object-name-parameter';
+ }
+
+ if (kind === 'unresolved') {
+ const row = UNTYPED_RECEIVERS.find((r) => r.file === rel && r.receiver === where.receiver);
+ if (row) {
+ usedRows.add(row);
+ if (row.engine) { kind = 'engine'; engineType = 'untyped receiver, placed by ledger'; placedBy = 'ledger'; }
+ else kind = 'other';
+ }
+ }
+
+ if (kind === 'other') { nonEngineCalls += 1; }
+ else if (kind === 'unresolved') {
+ unresolved.push({ ...where, verb, how: res.how, detail: res.detail ?? null, ledgered: false });
+ } else {
+ const ctx = tenantContextOf(node, sf, decls);
+ const decl = decided ? objects.get(objectName) : null;
+ sites.push({
+ ...where, verb, engineType, placedBy,
+ objectName,
+ objectNameKind: arg.kind,
+ objectDeclared: decided ? Boolean(decl) : null,
+ tenancy: decided
+ ? (decl ? (decl.tenancyDisabled ? 'disabled' : 'enabled') : 'undeclared-name')
+ : 'undecidable',
+ carriesTenantContext: ctx.carries,
+ contextHow: ctx.how,
+ elevatedContext: ctx.system,
+ });
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ }
+
+ sites.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
+ unresolved.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
+ const tenancyEnabled = sites.filter((s) => s.tenancy === 'enabled');
+ return {
+ sites,
+ unresolved,
+ unledgered: unresolved.filter((u) => !u.ledgered),
+ staleLedgerRows: UNTYPED_RECEIVERS.filter((r) => !usedRows.has(r)),
+ nonEngineCalls,
+ totals: {
+ writeCallSites: sites.length,
+ staticallyDecidableObjectName: sites.filter((s) => s.tenancy !== 'undecidable').length,
+ undecidableObjectName: sites.filter((s) => s.tenancy === 'undecidable').length,
+ objectNameInline: sites.filter((s) => s.objectNameKind === 'literal').length,
+ objectNameConst: sites.filter((s) => s.objectNameKind === 'const-literal').length,
+ objectNameParameter: sites.filter((s) => s.objectNameKind === 'object-name-parameter').length,
+ objectNameRuntime: sites.filter((s) => s.objectNameKind === 'runtime').length,
+ tenancyEnabled: tenancyEnabled.length,
+ tenancyDisabled: sites.filter((s) => s.tenancy === 'disabled').length,
+ provablyNoTenantContext: sites.filter((s) => s.carriesTenantContext === false).length,
+ tenantContextUnreadable: sites.filter((s) => s.carriesTenantContext === 'undecidable').length,
+ carriesTenantContext: sites.filter((s) => s.carriesTenantContext === true).length,
+ tenancyEnabledProvablyNoContext: tenancyEnabled.filter((s) => s.carriesTenantContext === false).length,
+ tenancyEnabledContextUnreadable: tenancyEnabled.filter((s) => s.carriesTenantContext === 'undecidable').length,
+ placedByObjectName: sites.filter((s) => s.placedBy === 'object-name').length,
+ placedByObjectNameParameter: sites.filter((s) => s.placedBy === 'object-name-parameter').length,
+ placedByLedger: sites.filter((s) => s.placedBy === 'ledger').length,
+ elevatedContext: sites.filter((s) => s.elevatedContext === true).length,
+ nonElevatedContext: sites.filter((s) => s.carriesTenantContext && s.elevatedContext === false).length,
+ elevationUndecidable: sites.filter((s) => s.elevatedContext === 'undecidable').length,
+ },
+ engineTypes: index.size,
+ declaredObjects: objects.size,
+ scannedSources: sources.length,
+ measuredAt: measuredAt(root),
+ };
+}
+
+export const PAGE = 'content/docs/permissions/tenant-audit-census.mdx';
+export const COUNTS = 'docs/audits/2026-08-tenant-audit-write-call-sites.counts.md';
+export const BEGIN_MARKER = '{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */}';
+export const END_MARKER = '{/* END GENERATED: tenant-audit-census */}';
+
+/**
+ * ⛔ NEITHER artefact carries LINE NUMBERS, and that is the design rather than an
+ * omission.
+ *
+ * An artefact keyed to line numbers reds on a pure DISPLACEMENT -- an inserted
+ * import above the site is enough -- so it churns on edits that changed nothing
+ * it measures, and its repair arm then has to tell displacement apart from a
+ * population change. That is a defect the sibling `isSystem` gate is carrying
+ * right now (a false "the POPULATION changed" refusal when only ledger-excused
+ * citations shifted), and inheriting its anchor scheme into a brand-new gate on
+ * day one would be a choice rather than an accident.
+ *
+ * So the rows are AGGREGATED: one per (file, verb, object name, tenancy, context
+ * posture), with a count. That key is invariant under displacement, so the only
+ * thing that can move these files is the population itself -- which is the only
+ * thing they claim to describe. `--json` still carries every site's `file:line`
+ * for anyone navigating to one.
+ *
+ * ## Why the rows live in `docs/audits/` and not on the page
+ *
+ * Same split, and the same reason, as `packages/spec`'s strictness ledger and its
+ * generated `.counts.md`: the page has prose to preserve and the row table has
+ * none, so the table is regenerated WHOLE while the page keeps a small generated
+ * region for the figures its prose reasons about. A reader gets a page they can
+ * read; a re-deriver gets a ledger they can diff.
+ *
+ * It also keeps 140-odd rows of machine output out of the published docs site,
+ * and out of `content/docs`-scoped prose ratchets that have no way to tell an
+ * emitted source path from an author's sentence -- `check-role-word` already
+ * excludes `content/docs/references/` for exactly that reason, and a hybrid page
+ * is a shape its directory-level exclusion cannot express.
+ */
+function aggregate(census) {
+ const groups = new Map();
+ for (const site of census.sites) {
+ const posture = site.carriesTenantContext === true
+ ? (site.elevatedContext === true ? 'elevated'
+ : site.elevatedContext === false ? 'tenant-scoped'
+ : 'context, elevation undecidable')
+ : site.carriesTenantContext === false ? 'PROVABLY NONE'
+ : 'options unreadable';
+ const key = JSON.stringify([site.file, site.verb, site.objectName, site.tenancy, posture]);
+ groups.set(key, (groups.get(key) ?? 0) + 1);
+ }
+ return [...groups.entries()]
+ .map(([key, count]) => ({ cells: JSON.parse(key), count }))
+ .sort((a, b) => a.cells[0].localeCompare(b.cells[0])
+ || a.cells[2].localeCompare(b.cells[2])
+ || a.cells[1].localeCompare(b.cells[1]));
+}
+
+/**
+ * The CORPUS-SCALE numbers: how big the haystack was, not what was found in it.
+ *
+ * ## ⭐ Why these four are rendered apart from the totals
+ *
+ * `census.totals` is the POPULATION this artefact certifies -- the write call
+ * sites and the tenancy/context verdict on each. These four are properties of the
+ * CORPUS the instrument walked: how many sources it read, how many engine-shaped
+ * types it recognised, how many objects the registry declares, and how many
+ * same-named calls on a non-engine receiver it declined to count. None of them is
+ * a property of the population. That is the line the enforced/unenforced split
+ * follows, and the code already drew it: everything inside `totals` is enforced,
+ * these four are not. `scripts/check-tenant-audit-census.mjs` carries the reason
+ * and the measurement.
+ *
+ * ⛔ They are still EMITTED and still DATED. "Not enforced" must not decay into
+ * "not there": a number nobody checks and nobody dates reads as current, which is
+ * the disease this whole artefact exists to treat one level down.
+ */
+export function corpusScaleRows(census) {
+ return [
+ ['tracked non-test sources scanned', census.scannedSources],
+ ['engine-shaped types recognised', census.engineTypes],
+ ['declared objects in the registry', census.declaredObjects],
+ ['same-named calls subtracted as non-engine', census.nonEngineCalls],
+ ];
+}
+
+/** The corpus-scale block, identical in both artefacts apart from heading depth. */
+export function renderCorpusScale(census, heading) {
+ const out = [];
+ out.push(`${heading} Corpus scale — present and dated, ⛔ NOT enforced`, '');
+ out.push('⛔ These four describe the CORPUS this census walked, not the population it');
+ out.push('certifies, and the gate deliberately does not hold them to the tree — a source');
+ out.push('file arriving anywhere under the two roots moves them while every verdict above');
+ out.push('holds still. They are required to be HERE and to say WHEN they were true;');
+ out.push('their values are not compared. The reasoning, and the measurement behind it,');
+ out.push('are in `scripts/check-tenant-audit-census.mjs`.', '');
+ out.push(`Measured on ${census.measuredAt.date} at \`${census.measuredAt.ref}\`.`, '');
+ out.push('| corpus scale (not enforced) | count |', '| :--- | ---: |');
+ for (const [label, value] of corpusScaleRows(census)) out.push(`| ${label} | ${value} |`);
+ return out;
+}
+
+/** The page's generated region: the figures its prose reasons about. */
+export function renderGeneratedRegion(census) {
+ const t = census.totals;
+ const out = [];
+ out.push(BEGIN_MARKER, '');
+ out.push('## The measurement', '');
+ out.push('| what | count |', '| :--- | ---: |');
+ out.push(`| write call sites on the application surface | **${t.writeCallSites}** |`);
+ out.push(`| …whose object name is statically decidable | ${t.staticallyDecidableObjectName} |`);
+ out.push(`| …whose object name is chosen at run time | ${t.undecidableObjectName} |`);
+ out.push(`| …against an object with tenancy ENABLED | ${t.tenancyEnabled} |`);
+ out.push(`| …against an object that declares tenancy off | ${t.tenancyDisabled} |`);
+ out.push(`| threading a tenant context | ${t.carriesTenantContext} |`);
+ out.push(`| PROVABLY carrying none (options read, no context key) | **${t.provablyNoTenantContext}** |`);
+ out.push(`| …of those, against a decidably tenancy-enabled object | **${t.tenancyEnabledProvablyNoContext}** |`);
+ out.push(`| options argument UNREADABLE — may or may not carry one | ${t.tenantContextUnreadable} |`);
+ out.push(`| …of those, against a decidably tenancy-enabled object | ${t.tenancyEnabledContextUnreadable} |`);
+ out.push(`| threading a decidably ELEVATED (\`isSystem\`) context | ${t.elevatedContext} |`);
+ out.push(`| threading a context that is decidably NOT elevated | ${t.nonElevatedContext} |`);
+ out.push(`| threading a context whose elevation is a run-time fact | ${t.elevationUndecidable} |`);
+ out.push('');
+ out.push('| how the instrument reached the site | count |', '| :--- | ---: |');
+ out.push(`| receiver carried a readable engine type | ${t.writeCallSites - t.placedByObjectName - t.placedByObjectNameParameter - t.placedByLedger} |`);
+ out.push(`| receiver erased, placed by the object NAME | ${t.placedByObjectName} |`);
+ out.push(`| receiver erased, placed by an \`object: string\` PARAMETER | ${t.placedByObjectNameParameter} |`);
+ out.push(`| receiver erased, placed by an \`UNTYPED_RECEIVERS\` row | ${t.placedByLedger} |`);
+ out.push('');
+ out.push(`| object name spelled inline | ${t.objectNameInline} |`);
+ out.push(`| object name spelled through a \`const\` | ${t.objectNameConst} |`);
+ out.push(`| object name is an \`object: string\` parameter | ${t.objectNameParameter} |`);
+ out.push(`| object name is some other run-time expression | ${t.objectNameRuntime} |`);
+ out.push('');
+ out.push(`The corpus walked is every tracked non-test source under \`packages/services/\``);
+ out.push(`and \`packages/plugins/\`; calls to a same-named method on something that is not`);
+ out.push(`a data engine were subtracted. Every site is listed in`);
+ out.push(`[\`${COUNTS}\`](https://github.com/objectstack-ai/objectstack/blob/main/${COUNTS}),`);
+ out.push(`regenerated by the same command.`);
+ out.push('');
+ out.push(...renderCorpusScale(census, '###'));
+ out.push('');
+ out.push(END_MARKER);
+ return out.join('\n');
+}
+
+/**
+ * The audit ledger: every site, regenerated WHOLE.
+ *
+ * No prose to preserve, so nothing here is spliced -- the file is rewritten. That
+ * is what makes `merge=os-regen` the right resolution for it, the same as its
+ * strictness-ledger sibling: two branches that each add a write call site produce
+ * rows that git merges cleanly and a header that merges cleanly and WRONG. The
+ * correct resolution is always "recompute from the merged tree".
+ */
+export function renderCountsFile(census) {
+ const t = census.totals;
+ const out = [];
+ out.push('');
+ out.push('');
+ out.push('');
+ out.push('# Tenant-audit census — every write call site (generated)');
+ out.push('');
+ out.push('Every application-surface write call site against a tenancy-enabled object, as');
+ out.push('`scripts/tenant-audit-census.mjs` derives it from the tree. **The prose, the');
+ out.push('method and the deviations from the figures this replaced are on the page**');
+ out.push('(`content/docs/permissions/tenant-audit-census.mdx`); this file has no prose to');
+ out.push('preserve and is regenerated whole.');
+ out.push('');
+ out.push('⛔ **Never hand-patch a row or a number here** — fix the code, or the census, and');
+ out.push('regenerate. `scripts/check-tenant-audit-census.mjs` fails the build when this file');
+ out.push('and the tree disagree.');
+ out.push('');
+ out.push('Rows are aggregated by (file, verb, object, tenancy, context posture) and carry no');
+ out.push('line numbers, so a pure displacement cannot move them. Run the generator with');
+ out.push('`--json` for per-site `file:line`.');
+ out.push('');
+ out.push('⚠️ **On a merge conflict here, regenerate — never resolve by hand.** Two branches');
+ out.push('that each add a write call site produce rows git merges cleanly and totals that');
+ out.push('merge cleanly and WRONG. This file is deliberately NOT `merge=os-regen`: that');
+ out.push('driver resolves an artefact\'s `gen:`/`check:` scripts in `@objectstack/spec`');
+ out.push('only, and these are root-level tooling. The gate is the backstop — a wrongly');
+ out.push('merged file fails `check-tenant-audit-census`, so the error is loud rather than');
+ out.push('silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.');
+ out.push('');
+ out.push('## Totals');
+ out.push('');
+ out.push('| Measure | Value |');
+ out.push('|---|---:|');
+ out.push(`| Write call sites | ${t.writeCallSites} |`);
+ out.push(`| Object name statically decidable | ${t.staticallyDecidableObjectName} |`);
+ out.push(`| Object name chosen at run time | ${t.undecidableObjectName} |`);
+ out.push(`| Against a tenancy-enabled object | ${t.tenancyEnabled} |`);
+ out.push(`| Against an object declaring tenancy off | ${t.tenancyDisabled} |`);
+ out.push(`| Threading a tenant context | ${t.carriesTenantContext} |`);
+ out.push(`| Provably carrying none | ${t.provablyNoTenantContext} |`);
+ out.push(`| …and decidably tenancy-enabled | ${t.tenancyEnabledProvablyNoContext} |`);
+ out.push(`| Options argument unreadable | ${t.tenantContextUnreadable} |`);
+ out.push(`| …and decidably tenancy-enabled | ${t.tenancyEnabledContextUnreadable} |`);
+ out.push(`| Threading a decidably elevated context | ${t.elevatedContext} |`);
+ out.push(`| Threading a decidably non-elevated context | ${t.nonElevatedContext} |`);
+ out.push(`| Threading a context of undecidable elevation | ${t.elevationUndecidable} |`);
+ out.push('');
+ out.push(...renderCorpusScale(census, '##'));
+ out.push('');
+ out.push('## Every site');
+ out.push('');
+ out.push('| file | verb | object | tenancy | tenant context | n |');
+ out.push('|---|---|---|---|---|---:|');
+ for (const r of aggregate(census)) {
+ const [file, verb, object, tenancy, context] = r.cells;
+ out.push(`| \`${file}\` | \`${verb}\` | \`${object}\` | ${tenancy} | ${context} | ${r.count} |`);
+ }
+ out.push('');
+ return out.join('\n');
+}
+
+/** Splice the generated region into the page text. */
+export function spliceRegion(pageText, region) {
+ const begin = pageText.indexOf(BEGIN_MARKER);
+ const end = pageText.indexOf(END_MARKER);
+ if (begin === -1 || end === -1) {
+ throw new Error(
+ `tenant-audit-census: ${PAGE} has no generated region -- expected the marker pair `
+ + '`BEGIN GENERATED: tenant-audit-census` / `END GENERATED: tenant-audit-census`. '
+ + 'Refusing to guess where the census belongs.',
+ );
+ }
+ return pageText.slice(0, begin) + region + pageText.slice(end + END_MARKER.length);
+}
+
+// ---------------------------------------------------------------------------
+// Self-test -- the only instrument on the classifiers. Run by the GATE's
+// `--self-test`, never by a flag of this module's own (see the header).
+// ---------------------------------------------------------------------------
+
+/**
+ * The classifier's defect class is a MATCHING RULE over shapes a clean tree
+ * contains only by accident, so a production run cannot tell a working rule from
+ * a weakened one: green means "no unplaceable receiver", and the elevation
+ * verdicts are not part of that verdict at all. They are published, and nothing
+ * else reads them.
+ *
+ * These cases are the shapes that were measured wrong. The first edition scored
+ * `{ ...SYSTEM_CTX }` as decidably NOT elevated and every `context: SYSTEM_CTX`
+ * as undecidable -- 51 sites' verdicts, six of them inverted outright -- because
+ * it skipped spreads and never unwrapped `as const`. Both are pinned here in the
+ * direction that failed, plus the direction that must NOT be over-claimed: a
+ * spread this cannot read makes the answer `undecidable`, never `false`.
+ */
+export function selfTest() {
+ const cases = [];
+ const t = (name, actual, expected) => cases.push({
+ name, ok: String(actual) === String(expected), detail: `got ${actual}, want ${expected}`,
+ });
+
+ /** Classify the `context:` of the single write call in a synthetic source. */
+ const classify = (src) => {
+ const sf = parseSourceFile('selftest.ts', src);
+ const decls = declaredTypesIn(sf);
+ let out = 'NO-CALL';
+ const visit = (node) => {
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
+ && WRITE_VERBS.includes(node.expression.name.text)) {
+ const ctx = tenantContextOf(node, sf, decls);
+ out = ctx.carries ? String(ctx.system) : 'NO-CONTEXT';
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ return out;
+ };
+
+ const call = (opts) => `declare const e: any;\ne.insert('o', {}, ${opts});\n`;
+
+ // ── the shapes that were measured WRONG ────────────────────────────────────
+ t('a `const … as const` context resolves through the assertion',
+ classify(`const SYSTEM_CTX = { isSystem: true } as const;\n${call('{ context: SYSTEM_CTX }')}`), true);
+ t('a SPREAD of an elevated const is elevated -- not "no isSystem key, so false"',
+ classify(`const SYSTEM_CTX = { isSystem: true } as const;\n${call('{ context: { ...SYSTEM_CTX } }')}`), true);
+ t('a spread of an elevated const survives extra keys beside it',
+ classify(`const S = { isSystem: true } as const;\n${call('{ context: { ...S, raw: true } }')}`), true);
+
+ // ── the direction that must not be OVER-claimed ────────────────────────────
+ t('an UNRESOLVABLE spread is undecidable, never false',
+ classify(`${call('{ context: { ...someImportedThing } }')}`), 'undecidable');
+ t('an unresolvable spread beside an unrelated key is still undecidable',
+ classify(`${call('{ context: { ...whatever, raw: true } }')}`), 'undecidable');
+ t('a spread of a const that does NOT mention the flag is undecidable, not false',
+ classify(`const C = { raw: true } as const;\n${call('{ context: { ...C, ...other } }')}`), 'undecidable');
+
+ // ── the ordinary verdicts, so the fix did not swallow them ─────────────────
+ t('an inline elevated literal is elevated',
+ classify(call('{ context: { isSystem: true } }')), true);
+ t('an inline literal that names the flag false is NOT elevated',
+ classify(call('{ context: { isSystem: false } }')), false);
+ t('an inline literal with no flag and no spread is NOT elevated',
+ classify(call('{ context: { userId: "u1" } }')), false);
+ t('a context from a helper CALL is undecidable',
+ classify(call('{ context: systemWriteContext(orgId) }')), 'undecidable');
+ t('a later key wins over an earlier spread',
+ classify(`const S = { isSystem: true } as const;\n${call('{ context: { ...S, isSystem: false } }')}`), false);
+ t('a write with no options argument carries no context',
+ classify(`declare const e: any;\ne.insert('o', {});\n`), 'NO-CONTEXT');
+ t('an options object with no context key carries no context',
+ classify(call('{ raw: true }')), 'NO-CONTEXT');
+
+ // ── the three-valued `carries`, whose middle value was the second over-claim ──
+ const carries = (opts) => {
+ const src = `declare const e: any;\ne.insert('o', {}, ${opts});\n`;
+ const sf = parseSourceFile('selftest.ts', src);
+ const decls = declaredTypesIn(sf);
+ let out = 'NO-CALL';
+ const visit = (node) => {
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
+ && WRITE_VERBS.includes(node.expression.name.text)) out = String(tenantContextOf(node, sf, decls).carries);
+ ts.forEachChild(node, visit);
+ };
+ visit(sf);
+ return out;
+ };
+ t('an UNREADABLE options argument is undecidable, never "carries no context"',
+ carries('opts'), 'undecidable');
+ t('an options literal carrying only a SPREAD is undecidable',
+ carries('{ ...opts }'), 'undecidable');
+ t('a READ options literal with no context key provably carries none',
+ carries('{ raw: true }'), 'false');
+ t('no options argument at all provably carries none',
+ (() => {
+ const sf = parseSourceFile('selftest.ts', "declare const e: any;\ne.insert('o', {});\n");
+ const decls = declaredTypesIn(sf);
+ let out = 'NO-CALL';
+ const visit = (n) => {
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)
+ && WRITE_VERBS.includes(n.expression.name.text)) out = String(tenantContextOf(n, sf, decls).carries);
+ ts.forEachChild(n, visit);
+ };
+ visit(sf);
+ return out;
+ })(), 'false');
+ t('a context key still reads as carried', carries('{ context: ctx }'), 'true');
+
+ const failed = cases.filter((c) => !c.ok);
+ for (const c of failed) console.error(` ✗ ${c.name} -- ${c.detail}`);
+ if (failed.length > 0) {
+ console.error(`✗ tenant-audit-census self-test: ${failed.length} of ${cases.length} case(s) failed.`);
+ return 1;
+ }
+ console.log(
+ `✓ tenant-audit-census self-test: ${cases.length} cases pass (an \`as const\` context, an `
+ + 'elevated SPREAD, an unresolvable spread refusing to answer `false`, an unreadable '
+ + 'options argument refusing to answer "carries no context", and the ordinary verdicts).',
+ );
+ return 0;
+}
+
+function main(argv) {
+
+ const c = runCensus();
+ if (argv.includes('--write')) {
+ for (const [rel, next] of [
+ [PAGE, spliceRegion(readFileSync(join(ROOT, PAGE), 'utf8'), renderGeneratedRegion(c))],
+ [COUNTS, renderCountsFile(c)],
+ ]) {
+ const abs = join(ROOT, rel);
+ const before = readFileSync(abs, 'utf8');
+ if (before === next) { process.stdout.write(`tenant-audit-census: ${rel} already current\n`); continue; }
+ writeFileSync(abs, next);
+ process.stdout.write(`tenant-audit-census: rewrote ${rel}\n`);
+ }
+ return 0;
+ }
+ if (argv.includes('--json')) {
+ process.stdout.write(`${JSON.stringify(c, null, 2)}\n`);
+ } else {
+ const t = c.totals;
+ process.stdout.write([
+ `tenant-audit-census: ${t.writeCallSites} engine write call sites on the application surface`,
+ ` sources scanned ${c.scannedSources} · engine-shaped types ${c.engineTypes} · declared objects ${c.declaredObjects}`,
+ ` object name decidable ${t.staticallyDecidableObjectName} · undecidable ${t.undecidableObjectName}`,
+ ` inline literal ${t.objectNameInline} · const ${t.objectNameConst} · name parameter ${t.objectNameParameter} · other runtime ${t.objectNameRuntime}`,
+ ` tenancy enabled ${t.tenancyEnabled} · declared off ${t.tenancyDisabled}`,
+ ` tenant context: carried ${t.carriesTenantContext} · provably absent ${t.provablyNoTenantContext} · unreadable ${t.tenantContextUnreadable}`,
+ ` provably absent AND tenancy-enabled ${t.tenancyEnabledProvablyNoContext} · unreadable AND tenancy-enabled ${t.tenancyEnabledContextUnreadable}`,
+ ` threads a context: elevated ${t.elevatedContext} · not elevated ${t.nonElevatedContext} · undecidable ${t.elevationUndecidable}`,
+ ` untyped receivers placed: by object name ${t.placedByObjectName} · by name parameter ${t.placedByObjectNameParameter} · by ledger ${t.placedByLedger}`,
+ ` non-engine calls subtracted ${c.nonEngineCalls} · unresolved receivers ${c.unresolved.length}`,
+ '',
+ ].join('\n'));
+ }
+ for (const u of c.unledgered) {
+ process.stderr.write(`::error::[untyped-receiver] ${u.file}:${u.line} \`${u.receiver}\`.${u.verb}() -- `
+ + `receiver type unreadable [${u.how}] and the object name is not a literal declared object. `
+ + `Add an UNTYPED_RECEIVERS row saying what it is.\n`);
+ }
+ for (const r of c.staleLedgerRows) {
+ process.stderr.write(`::error::[stale-ledger-row] UNTYPED_RECEIVERS names ${r.file} (receiver `
+ + `\`${r.receiver}\`) but no such write call exists -- delete the row.\n`);
+ }
+ return c.unledgered.length === 0 && c.staleLedgerRows.length === 0 ? 0 : 1;
+}
+
+if (isEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));