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)));