From 90315074619a3ab7a200704438b9674599fcd983 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:19:53 +0000 Subject: [PATCH 1/9] Re-derive the tenant-audit write-call-site census as an in-tree artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurement that decides this repair family's severity lived in a comment on issue #13178. That issue became unreachable — 404 on unauthenticated REST, on the rendered page and on authenticated MCP alike, while its neighbours answer 200 — and took the census with it while three open cards still named it as their input. What survived did so by luck: a changeset author had quoted two figures in prose. Adds the isSystem census triple's shape for the tenant-audit control: scripts/tenant-audit-census.mjs the instrument content/docs/permissions/...mdx the page (prose + generated figures) docs/audits/...counts.md the per-site ledger, regenerated whole scripts/check-tenant-audit-census.mjs the gate It is a DRIFT gate, deliberately not an anchor gate: rows aggregate by (file, verb, object, tenancy, context posture) and carry no line numbers, so a pure displacement cannot move them. That skips the sibling's open defect class — anchors that rot on displacement and a repair arm that then misreads displacement as a population change. Measured: 214 write call sites, 143 statically decidable, 41 tenancy-enabled carrying no tenant context. The deviations from all four carried figures are recorded on the page untrimmed, including that the 135/77% "silenced by the isSystem guard" figure has no surviving corroboration and is not reproduced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- .claude/workflows/docs-accuracy-audit.js | 1 + .github/workflows/lint.yml | 39 + content/docs/permissions/meta.json | 1 + .../docs/permissions/tenant-audit-census.mdx | 176 +++ ...08-tenant-audit-write-call-sites.counts.md | 188 +++ scripts/check-tenant-audit-census.mjs | 418 +++++++ scripts/tenant-audit-census.mjs | 1105 +++++++++++++++++ 7 files changed, 1928 insertions(+) create mode 100644 content/docs/permissions/tenant-audit-census.mdx create mode 100644 docs/audits/2026-08-tenant-audit-write-call-sites.counts.md create mode 100644 scripts/check-tenant-audit-census.mjs create mode 100644 scripts/tenant-audit-census.mjs 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..cc4a7a8318 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1381,6 +1381,45 @@ 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. + # + # 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 533 + # tracked sources plus the object registry, no spawns; ~8s. + - 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/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..5cc0010263 --- /dev/null +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -0,0 +1,176 @@ +--- +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, 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 for elevation. A context spelled inline as `{ isSystem: true }`, or +a local `const` holding one, is decidable; a context arriving from a helper call +or a parameter is not. + +⇒ **A third of this population is undecidable, 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. + +## ⭐ 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 | **214** | +| 24 carrying no tenant context | quoted in the merged changeset | **41** (decidably tenancy-enabled); **83** across all sites | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 214** decidable, **71** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 46 decidably elevated, 8 decidably not, 77 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**, and this census does +not reproduce it: 46 sites are decidably elevated, and 77 have an elevation that +is a run-time fact a static reading cannot settle. Cite `41 / 214` — both halves +re-derivable here — rather than any share of the population that was silenced. + +{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} + +## The measurement + +| what | count | +| :--- | ---: | +| write call sites on the application surface | **214** | +| …whose object name is statically decidable | 143 | +| …whose object name is chosen at run time | 71 | +| …against an object with tenancy ENABLED | 143 | +| …against an object that declares tenancy off | 0 | +| carrying NO tenant context at all | **83** | +| …of those, against a decidably tenancy-enabled object | **41** | +| threading a decidably ELEVATED (`isSystem`) context | 46 | +| threading a context that is decidably NOT elevated | 8 | +| threading a context whose elevation is a run-time fact | 77 | + +| how the instrument reached the site | count | +| :--- | ---: | +| receiver carried a readable engine type | 169 | +| 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 | 18 | +| object name is some other run-time expression | 53 | + +Scanned 533 tracked non-test sources under `packages/services/` and +`packages/plugins/`, against 56 engine-shaped types and +297 declared objects. 119 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. + +{/* 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..9f275762b7 --- /dev/null +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -0,0 +1,188 @@ + + + +# 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 | 214 | +| Object name statically decidable | 143 | +| Object name chosen at run time | 71 | +| Against a tenancy-enabled object | 143 | +| Against an object declaring tenancy off | 0 | +| Carrying no tenant context | 83 | +| …and decidably tenancy-enabled | 41 | +| Threading a decidably elevated context | 46 | +| Threading a decidably non-elevated context | 8 | +| Threading a context of undecidable elevation | 77 | +| Sources scanned | 533 | +| 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 | NONE | 1 | +| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | NONE | 1 | +| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `insert` | `sys_audit_log` | enabled | NONE | 1 | +| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `update` | `sys_user` | enabled | NONE | 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 | NONE | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_account` | enabled | NONE | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | NONE | 3 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | NONE | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | NONE | 8 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | NONE | 1 | +| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | 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 | NONE | 1 | +| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | NONE | 1 | +| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `m` | undecidable | NONE | 1 | +| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | NONE | 5 | +| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | NONE | 2 | +| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | NONE | 5 | +| `packages/plugins/plugin-auth/src/phone-sms-texts.ts` | `insert` | `sys_notification_template` | enabled | context, elevation undecidable | 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 | NONE | 1 | +| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | NONE | 1 | +| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | +| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | context, elevation undecidable | 2 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | context, elevation undecidable | 1 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_report_schedule` | enabled | context, elevation undecidable | 2 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_report_schedule` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_report_schedule` | enabled | context, elevation undecidable | 2 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_saved_report` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_saved_report` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_saved_report` | enabled | context, elevation undecidable | 2 | +| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | context, elevation undecidable | 1 | +| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 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 | context, elevation undecidable | 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 | context, elevation undecidable | 2 | +| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | NONE | 2 | +| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | context, elevation undecidable | 2 | +| `packages/plugins/plugin-sharing/src/sharing-plugin.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | +| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_record_share` | enabled | NONE | 3 | +| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_sharing_rule` | enabled | NONE | 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 | context, elevation undecidable | 2 | +| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `insert` | `sys_record_share` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `update` | `sys_record_share` | enabled | context, elevation undecidable | 1 | +| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `insert` | `subscriptionsObject` | undecidable | NONE | 1 | +| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `update` | `subscriptionsObject` | undecidable | NONE | 1 | +| `packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts` | `update` | `subscriptionsObject` | undecidable | NONE | 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 | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | +| `packages/services/service-automation/src/suspended-run-store.ts` | `insert` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | +| `packages/services/service-automation/src/suspended-run-store.ts` | `update` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | NONE | 1 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | NONE | 1 | +| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | NONE | 2 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | NONE | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | NONE | 1 | +| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | context, elevation undecidable | 3 | +| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job_run` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/messaging-service.ts` | `update` | `RECEIPT_OBJECT` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | NONE | 1 | +| `packages/services/service-messaging/src/sql-http-outbox.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | NONE | 4 | +| `packages/services/service-messaging/src/sql-outbox.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | +| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | NONE | 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 | NONE | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | NONE | 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 | NONE | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | NONE | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | NONE | 1 | +| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | +| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | NONE | 1 | +| `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | tenant-scoped | 3 | +| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | tenant-scoped | 1 | +| `packages/services/service-storage/src/backfill-file-references.ts` | `insert` | `sys_file` | enabled | tenant-scoped | 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 | tenant-scoped | 1 | +| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `update` | `sys_file` | enabled | tenant-scoped | 2 | +| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_file` | enabled | NONE | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_file` | enabled | NONE | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_file` | enabled | NONE | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_upload_session` | enabled | NONE | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_upload_session` | enabled | NONE | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_upload_session` | enabled | NONE | 1 | diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs new file mode 100644 index 0000000000..f16101ca91 --- /dev/null +++ b/scripts/check-tenant-audit-census.mjs @@ -0,0 +1,418 @@ +#!/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. + * 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. + * + * 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, + renderCountsFile, + renderGeneratedRegion, + runCensus, +} from './tenant-audit-census.mjs'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** + * 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: 'declared objects in the registry', + pattern: /Across (\d+) declared objects/, + expected: (c) => c.declaredObjects, + }, + { + name: 'deviation row: write call sites', + pattern: /\| 175 write call sites \|[^|]*\| \*\*(\d+)\*\* \|/, + expected: (c) => c.totals.writeCallSites, + }, + { + name: 'deviation row: without tenant context, tenancy-enabled', + pattern: /\| 24 carrying no tenant context \|[^|]*\| \*\*(\d+)\*\* \(decidably tenancy-enabled\)/, + expected: (c) => c.totals.tenancyEnabledWithoutTenantContext, + }, + { + name: 'deviation row: without tenant context, all sites', + pattern: /\(decidably tenancy-enabled\); \*\*(\d+)\*\* across all sites \|/, + expected: (c) => c.totals.withoutTenantContext, + }, + { + 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 figure downstream cards should cite', + pattern: /Cite `(\d+) \/ \d+`/, + expected: (c) => c.totals.tenancyEnabledWithoutTenantContext, + }, + { + name: 'the population that figure is of', + pattern: /Cite `\d+ \/ (\d+)`/, + expected: (c) => c.totals.writeCallSites, + }, +]; + +/** 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 ──────────────────────────────────────────────────────────────── + for (const [label, committed, expected] of [ + [PAGE, split.region, renderGeneratedRegion(census)], + [COUNTS, countsText, renderCountsFile(census)], + ]) { + if (committed === expected) continue; + const diff = firstDifference(committed ?? '', expected); + 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.', + ); + } + } + + 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 ──────────────────────────────────────────────────────────────── + t('a stale hand-written number in the prose is a finding', + check(page.replace('Across 297 declared objects', 'Across 296 declared objects')) + .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('Across 297 declared objects', 'Across many declared objects')) + .some((p) => p.startsWith('[prose-pattern-dead]'))); + + t('the "cite this figure" line is held to the census', + check(page.replace( + `Cite \`${census.totals.tenancyEnabledWithoutTenantContext} / ${census.totals.writeCallSites}\``, + `Cite \`24 / ${census.totals.writeCallSites}\``, + )).some((p) => p.startsWith('[prose-count]'))); + + // ── 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; + } + 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).', + ); + 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.tenancyEnabledWithoutTenantContext} ` + + `tenancy-enabled with no tenant context), ${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..9f7bb89973 --- /dev/null +++ b/scripts/tenant-audit-census.mjs @@ -0,0 +1,1105 @@ +#!/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 + * + * `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: 297 objects, of which exactly two + * (`sys_api_key`, `sys_sso_provider`) opt out. + * + * ## 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. + * + * ## 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; +} + +/** 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. + * + * Both halves matter, and they answer different questions about the same guard + * chain. `auditMissingTenant` exits early on a present `options.tenantId` + * (the engine fills it from `execCtx.tenantId`), and separately the engine sets + * `bypassTenantAudit` for every `execCtx.isSystem` write -- a guard that sits + * BEFORE the deployment-posture gate. So: + * + * - `carries: false` -- the site threads nothing. This is the population the + * control exists to surface. + * - `carries: true, system: true` -- the site threads an ELEVATED context. It + * is scoped by decision, and it is silenced by the `bypassTenantAudit` arm + * rather than by carrying a tenant. + * - `carries: true, system: 'undecidable'` -- a context arrives from a helper + * call or a parameter, and whether it is elevated is a run-time fact. Said + * rather than guessed. + */ +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 sawOpaque = null; + for (const a of args) { + if (ts.isObjectLiteralExpression(a)) { + for (const prop of a.properties) { + if (ts.isSpreadAssignment(prop)) sawOpaque = `spread ${prop.expression.getText(sf)}`; + 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) { + sawOpaque = a.getText(sf).replace(/\s+/g, ' ').slice(0, 60); + } + } + if (sawOpaque) return { carries: false, how: 'opaque-options', opaque: sawOpaque, system: false }; + return { carries: false, how: 'no-context-key', system: false }; +} + +/** Is this context expression an ELEVATED (`isSystem: true`) one? */ +function elevationOf(value, sf, decls) { + if (value == null) return 'undecidable'; + const literalSaysSystem = (node) => { + if (!node || !ts.isObjectLiteralExpression(node)) return null; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop) || !prop.name || !ts.isIdentifier(prop.name)) continue; + if (prop.name.text !== 'isSystem') continue; + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) return true; + if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) return false; + return 'undecidable'; + } + return false; + }; + const direct = literalSaysSystem(value); + if (direct !== null) return direct; + if (ts.isIdentifier(value)) { + const entry = decls?.locals.get(value.text); + const viaConst = literalSaysSystem(entry?.node); + if (viaConst !== null) return viaConst; + } + return 'undecidable'; +} + +/** + * ⛔ 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, + withoutTenantContext: sites.filter((s) => !s.carriesTenantContext).length, + tenancyEnabledWithoutTenantContext: tenancyEnabled.filter((s) => !s.carriesTenantContext).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, + }; +} + +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 + ? (site.elevatedContext === true ? 'elevated' + : site.elevatedContext === false ? 'tenant-scoped' + : 'context, elevation undecidable') + : 'NONE'; + 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 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(`| carrying NO tenant context at all | **${t.withoutTenantContext}** |`); + out.push(`| …of those, against a decidably tenancy-enabled object | **${t.tenancyEnabledWithoutTenantContext}** |`); + 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(`Scanned ${census.scannedSources} tracked non-test sources under \`packages/services/\` and`); + out.push(`\`packages/plugins/\`, against ${census.engineTypes} engine-shaped types and`); + out.push(`${census.declaredObjects} declared objects. ${census.nonEngineCalls} calls to a same-named`); + out.push(`method on something that is not a data engine were subtracted. Every site is`); + out.push(`listed in [\`${COUNTS}\`](https://github.com/objectstack-ai/objectstack/blob/main/${COUNTS}),`); + out.push(`regenerated by the same command.`); + 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(`| Carrying no tenant context | ${t.withoutTenantContext} |`); + out.push(`| …and decidably tenancy-enabled | ${t.tenancyEnabledWithoutTenantContext} |`); + 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(`| Sources scanned | ${census.scannedSources} |`); + out.push(`| Engine-shaped types recognised | ${census.engineTypes} |`); + out.push(`| Declared objects in the registry | ${census.declaredObjects} |`); + out.push(`| Same-named calls subtracted as non-engine | ${census.nonEngineCalls} |`); + 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); +} + +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}`, + ` no tenant context ${t.withoutTenantContext} (tenancy-enabled: ${t.tenancyEnabledWithoutTenantContext})`, + ` 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))); From 77cd51960f66e4036640ace99f4a24f5af07baff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:47:09 +0000 Subject: [PATCH 2/9] Regenerate the tenant-audit census against the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/plugins/plugin-security/src/platform-admin-service.ts` landed on main between this branch's census run and CI's, so the committed artifacts named 533 scanned sources where the tree holds 534 — and the drift gate caught its own artifact within the hour of it being written. Population unchanged: the new source contains no engine write call site (no insert/update/delete call syntax at all; the census places 0 sites and 0 unresolved receivers in it). 214 / 143 / 41 all hold, so the page's "cite 41 / 214" redirection needs no edit. Only `Sources scanned` moves, in both artifacts, which is exactly the shape "+1 scanned source, +0 population" should take. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- content/docs/permissions/tenant-audit-census.mdx | 2 +- docs/audits/2026-08-tenant-audit-write-call-sites.counts.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 5cc0010263..a81e78004d 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -166,7 +166,7 @@ re-derivable here — rather than any share of the population that was silenced. | object name is an `object: string` parameter | 18 | | object name is some other run-time expression | 53 | -Scanned 533 tracked non-test sources under `packages/services/` and +Scanned 534 tracked non-test sources under `packages/services/` and `packages/plugins/`, against 56 engine-shaped types and 297 declared objects. 119 calls to a same-named method on something that is not a data engine were subtracted. Every site is 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 index 9f275762b7..d1d9367e52 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -39,7 +39,7 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Threading a decidably elevated context | 46 | | Threading a decidably non-elevated context | 8 | | Threading a context of undecidable elevation | 77 | -| Sources scanned | 533 | +| Sources scanned | 534 | | Engine-shaped types recognised | 56 | | Declared objects in the registry | 297 | | Same-named calls subtracted as non-engine | 119 | From ed0df3846f8e46402e07245b8df81d8a2330566f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:01:57 +0000 Subject: [PATCH 3/9] List the census page in the permissions module index `check-section-landing-index` holds each section's "What's in this module" block to its meta.json in both directions, and the merge brought that gate into this card's derived family. Adding the page to meta.json without a row here left the reader's map short by one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- content/docs/permissions/index.mdx | 1 + 1 file changed, 1 insertion(+) 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) From 32997ca6616a3ae88f320276665c6cf26ae6041f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:29:12 +0000 Subject: [PATCH 4/9] wip: elevation classifier fix + self-test --- scripts/tenant-audit-census.mjs | 177 ++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 18 deletions(-) diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 9f7bb89973..a1537aa81f 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -579,28 +579,79 @@ export function tenantContextOf(node, sf, decls) { return { carries: false, how: 'no-context-key', system: false }; } -/** Is this context expression an ELEVATED (`isSystem: true`) one? */ -function elevationOf(value, sf, decls) { - if (value == null) return 'undecidable'; - const literalSaysSystem = (node) => { - if (!node || !ts.isObjectLiteralExpression(node)) return null; - for (const prop of node.properties) { +/** + * 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) return true; - if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) return false; - return 'undecidable'; + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) verdict = true; + else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) verdict = false; + else return 'undecidable'; } - return false; + // 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 direct = literalSaysSystem(value); - if (direct !== null) return direct; - if (ts.isIdentifier(value)) { - const entry = decls?.locals.get(value.text); - const viaConst = literalSaysSystem(entry?.node); - if (viaConst !== null) return viaConst; - } - return 'undecidable'; + + const answer = readLiteral(value, depth); + return answer === null ? 'undecidable' : answer; } /** @@ -1058,7 +1109,97 @@ export function spliceRegion(pageText, region) { return pageText.slice(0, begin) + region + pageText.slice(end + END_MARKER.length); } +// --------------------------------------------------------------------------- +// Self-test -- the only instrument on the elevation classifier +// --------------------------------------------------------------------------- + +/** + * 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'); + + 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`, and the ordinary verdicts).', + ); + return 0; +} + function main(argv) { + if (argv.includes('--self-test')) return selfTest(); + const c = runCensus(); if (argv.includes('--write')) { for (const [rel, next] of [ From d205814f0a6f4e2d238427cb9411419a89b78cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:34:55 +0000 Subject: [PATCH 5/9] Correct two over-claims in the census's own classifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift gate fired on this page's artifact twice in an hour, and tracing the second one found the numbers themselves were wrong in two places. Both are the shape this artifact exists to stop — a thing the walker could not read, scored as a thing it had read — wearing opposite hats. ELEVATION read a spread as a denial. It walked an object literal's named properties for `isSystem` and returned "not elevated" when the loop ended, so `{ ...SYSTEM_CTX }` scored decidably NOT elevated while every SYSTEM_CTX in the tree is `{ isSystem: true }`. It never unwrapped `as const` either, so a bare `context: SYSTEM_CTX` read as undecidable. 51 verdicts move: 45 undecidable -> elevated, 6 inverted outright. Decidably-not-elevated is now 0. TENANT CONTEXT counted sites it had not read. An unreadable options argument was folded in with one read and found empty, publishing "84 sites carrying no tenant context at all" when 17 said so and 67 were unread — an over-claim in the alarming direction, on the figure the page tells other cards to cite. `carries` is now three-valued; the provable count is 17 (9 tenancy-enabled), with 67 (32) unreadable. The generator gains its own `--self-test` (18 cases, 6 red against the old reading) and CI runs it: these classifiers are published FIGURES, not findings, so the gate's verdict never covered them and a clean tree could not tell a working classifier from a weakened one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- .github/workflows/lint.yml | 13 +- .../docs/permissions/tenant-audit-census.mdx | 97 ++++++--- ...08-tenant-audit-write-call-sites.counts.md | 198 +++++++++--------- scripts/check-tenant-audit-census.mjs | 70 +++++-- scripts/tenant-audit-census.mjs | 116 +++++++--- 5 files changed, 329 insertions(+), 165 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc4a7a8318..6b0671050e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1412,11 +1412,22 @@ jobs: # 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 533 + # GATE INVOCATION IDIOM note at the top of this file. AST walk over 534 # tracked sources plus the object registry, no spawns; ~8s. - name: Tenant-audit census matches the tree run: | + node scripts/tenant-audit-census.mjs --self-test node scripts/check-tenant-audit-census.mjs --self-test node scripts/check-tenant-audit-census.mjs diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a81e78004d..04195e9e0c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -93,17 +93,49 @@ targets either. 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 for elevation. A context spelled inline as `{ isSystem: true }`, or -a local `const` holding one, is decidable; a context arriving from a helper call -or a parameter is not. - -⇒ **A third of this population is undecidable, 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. +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/tenant-audit-census.mjs +--self-test` pins all of it — 18 cases, 6 of which red against the old reading. ## ⭐ Deviations from the carried figures, untrimmed @@ -113,10 +145,10 @@ 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 | **214** | -| 24 carrying no tenant context | quoted in the merged changeset | **41** (decidably tenancy-enabled); **83** across all sites | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 214** decidable, **71** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 46 decidably elevated, 8 decidably not, 77 undecidable | +| 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 @@ -132,10 +164,16 @@ through an erased (`any`) receiver, and the 37 that name their object through a 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**, and this census does -not reproduce it: 46 sites are decidably elevated, and 77 have an elevation that -is a run-time fact a static reading cannot settle. Cite `41 / 214` — both halves -re-derivable here — rather than any share of the population that was silenced. +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 */} @@ -143,27 +181,30 @@ re-derivable here — rather than any share of the population that was silenced. | what | count | | :--- | ---: | -| write call sites on the application surface | **214** | +| write call sites on the application surface | **215** | | …whose object name is statically decidable | 143 | -| …whose object name is chosen at run time | 71 | +| …whose object name is chosen at run time | 72 | | …against an object with tenancy ENABLED | 143 | | …against an object that declares tenancy off | 0 | -| carrying NO tenant context at all | **83** | -| …of those, against a decidably tenancy-enabled object | **41** | -| threading a decidably ELEVATED (`isSystem`) context | 46 | -| threading a context that is decidably NOT elevated | 8 | -| threading a context whose elevation is a run-time fact | 77 | +| 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 | 169 | +| 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 | 18 | +| object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 53 | Scanned 534 tracked non-test sources under `packages/services/` and 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 index d1d9367e52..4154735434 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -29,16 +29,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 214 | +| Write call sites | 215 | | Object name statically decidable | 143 | -| Object name chosen at run time | 71 | +| Object name chosen at run time | 72 | | Against a tenancy-enabled object | 143 | | Against an object declaring tenancy off | 0 | -| Carrying no tenant context | 83 | -| …and decidably tenancy-enabled | 41 | -| Threading a decidably elevated context | 46 | -| Threading a decidably non-elevated context | 8 | -| Threading a context of undecidable elevation | 77 | +| 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 | | Sources scanned | 534 | | Engine-shaped types recognised | 56 | | Declared objects in the registry | 297 | @@ -57,50 +60,50 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | `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 | NONE | 1 | -| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | NONE | 1 | -| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `insert` | `sys_audit_log` | enabled | NONE | 1 | -| `packages/plugins/plugin-auth/src/admin-import-users.ts` | `update` | `sys_user` | enabled | NONE | 2 | +| `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 | NONE | 1 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_account` | enabled | NONE | 1 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | NONE | 3 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | NONE | 1 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | NONE | 8 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | NONE | 1 | -| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | NONE | 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 | NONE | 1 | -| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | NONE | 1 | -| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `m` | undecidable | NONE | 1 | -| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | NONE | 5 | -| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | NONE | 2 | -| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | NONE | 5 | -| `packages/plugins/plugin-auth/src/phone-sms-texts.ts` | `insert` | `sys_notification_template` | enabled | context, elevation undecidable | 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 | NONE | 1 | -| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | NONE | 1 | -| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts` | `update` | `object` | undecidable | context, elevation undecidable | 2 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email_template` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email_template` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_report_schedule` | enabled | context, elevation undecidable | 2 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_report_schedule` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_report_schedule` | enabled | context, elevation undecidable | 2 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `delete` | `sys_saved_report` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `insert` | `sys_saved_report` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-reports/src/report-service.ts` | `update` | `sys_saved_report` | enabled | context, elevation undecidable | 2 | -| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | 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 | @@ -111,7 +114,7 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | `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 | context, elevation undecidable | 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 | @@ -122,67 +125,68 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | `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 | context, elevation undecidable | 2 | -| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | NONE | 2 | -| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | context, elevation undecidable | 2 | -| `packages/plugins/plugin-sharing/src/sharing-plugin.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_record_share` | enabled | NONE | 3 | -| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_sharing_rule` | enabled | NONE | 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 | context, elevation undecidable | 2 | -| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `insert` | `sys_record_share` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `update` | `sys_record_share` | enabled | context, elevation undecidable | 1 | -| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `insert` | `subscriptionsObject` | undecidable | NONE | 1 | -| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `update` | `subscriptionsObject` | undecidable | NONE | 1 | -| `packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts` | `update` | `subscriptionsObject` | undecidable | NONE | 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 | context, elevation undecidable | 1 | -| `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | -| `packages/services/service-automation/src/suspended-run-store.ts` | `insert` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | -| `packages/services/service-automation/src/suspended-run-store.ts` | `update` | `sys_automation_run` | enabled | context, elevation undecidable | 2 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | NONE | 1 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | NONE | 1 | -| `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | NONE | 2 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | NONE | 1 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | NONE | 1 | -| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | context, elevation undecidable | 1 | -| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | context, elevation undecidable | 3 | -| `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | context, elevation undecidable | 1 | -| `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job_run` | enabled | context, elevation undecidable | 1 | -| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/messaging-service.ts` | `update` | `RECEIPT_OBJECT` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `sys_notification` | enabled | NONE | 1 | -| `packages/services/service-messaging/src/sql-http-outbox.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/sql-http-outbox.ts` | `update` | `this.objectName` | undecidable | NONE | 4 | -| `packages/services/service-messaging/src/sql-outbox.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | -| `packages/services/service-messaging/src/sql-outbox.ts` | `update` | `this.objectName` | undecidable | NONE | 5 | +| `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 | NONE | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | NONE | 2 | +| `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 | NONE | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | NONE | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | NONE | 1 | -| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | NONE | 1 | -| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | NONE | 1 | -| `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | tenant-scoped | 3 | -| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | tenant-scoped | 1 | -| `packages/services/service-storage/src/backfill-file-references.ts` | `insert` | `sys_file` | enabled | tenant-scoped | 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 | tenant-scoped | 1 | -| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `update` | `sys_file` | enabled | tenant-scoped | 2 | -| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_file` | enabled | NONE | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_file` | enabled | NONE | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_file` | enabled | NONE | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_upload_session` | enabled | NONE | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_upload_session` | enabled | NONE | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_upload_session` | enabled | NONE | 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 index f16101ca91..dabc348d16 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -126,20 +126,50 @@ export const PROSE_COUNTS = [ pattern: /Across (\d+) declared objects/, expected: (c) => c.declaredObjects, }, + { + 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: without tenant context, tenancy-enabled', - pattern: /\| 24 carrying no tenant context \|[^|]*\| \*\*(\d+)\*\* \(decidably tenancy-enabled\)/, - expected: (c) => c.totals.tenancyEnabledWithoutTenantContext, + 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: without tenant context, all sites', - pattern: /\(decidably tenancy-enabled\); \*\*(\d+)\*\* across all sites \|/, - expected: (c) => c.totals.withoutTenantContext, + 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', @@ -171,16 +201,31 @@ export const PROSE_COUNTS = [ 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.tenancyEnabledWithoutTenantContext, + 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, + }, ]; /** Split the page into its hand-written prose and its generated region. */ @@ -339,7 +384,7 @@ export function selfTest() { // ── B PROSE ──────────────────────────────────────────────────────────────── t('a stale hand-written number in the prose is a finding', - check(page.replace('Across 297 declared objects', 'Across 296 declared objects')) + check(page.replace(`Across ${census.declaredObjects} declared objects`, `Across ${census.declaredObjects - 1} declared objects`)) .some((p) => p.startsWith('[prose-count]'))); t('a stale number in the DEVIATION table is a finding', @@ -352,12 +397,12 @@ export function selfTest() { // 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('Across 297 declared objects', 'Across many declared objects')) + check(page.replace(`Across ${census.declaredObjects} declared objects`, 'Across many declared objects')) .some((p) => p.startsWith('[prose-pattern-dead]'))); t('the "cite this figure" line is held to the census', check(page.replace( - `Cite \`${census.totals.tenancyEnabledWithoutTenantContext} / ${census.totals.writeCallSites}\``, + `Cite \`${census.totals.tenancyEnabledProvablyNoContext} / ${census.totals.writeCallSites}\``, `Cite \`24 / ${census.totals.writeCallSites}\``, )).some((p) => p.startsWith('[prose-count]'))); @@ -409,8 +454,9 @@ function main(argv) { const t = census.totals; console.log( `✓ check-tenant-audit-census: OK -- ${t.writeCallSites} write call sites certified ` - + `(${t.staticallyDecidableObjectName} decidable, ${t.tenancyEnabledWithoutTenantContext} ` - + `tenancy-enabled with no tenant context), ${PROSE_COUNTS.length} prose figures held to the census.`, + + `(${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; } diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index a1537aa81f..b2a110358a 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -540,29 +540,43 @@ export function declaredObjects(root = ROOT) { * What execution context, if any, this write call threads -- and whether that * context is ELEVATED. * - * Both halves matter, and they answer different questions about the same guard - * chain. `auditMissingTenant` exits early on a present `options.tenantId` - * (the engine fills it from `execCtx.tenantId`), and separately the engine sets - * `bypassTenantAudit` for every `execCtx.isSystem` write -- a guard that sits - * BEFORE the deployment-posture gate. So: + * ## ⛔ Three answers, because "I could not read it" is not "there is none" * - * - `carries: false` -- the site threads nothing. This is the population the - * control exists to surface. - * - `carries: true, system: true` -- the site threads an ELEVATED context. It - * is scoped by decision, and it is silenced by the `bypassTenantAudit` arm - * rather than by carrying a tenant. - * - `carries: true, system: 'undecidable'` -- a context arrives from a helper - * call or a parameter, and whether it is elevated is a run-time fact. Said - * rather than guessed. + * `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 sawOpaque = null; + 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)) sawOpaque = `spread ${prop.expression.getText(sf)}`; + 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; @@ -572,10 +586,11 @@ export function tenantContextOf(node, sf, decls) { } } else if (!ts.isStringLiteralLike(a) && !ts.isNumericLiteral(a) && a.kind !== ts.SyntaxKind.TrueKeyword && a.kind !== ts.SyntaxKind.FalseKeyword) { - sawOpaque = a.getText(sf).replace(/\s+/g, ' ').slice(0, 60); + opaque = a.getText(sf).replace(/\s+/g, ' ').slice(0, 60); } } - if (sawOpaque) return { carries: false, how: 'opaque-options', opaque: sawOpaque, system: false }; + 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 }; } @@ -915,8 +930,11 @@ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { objectNameRuntime: sites.filter((s) => s.objectNameKind === 'runtime').length, tenancyEnabled: tenancyEnabled.length, tenancyDisabled: sites.filter((s) => s.tenancy === 'disabled').length, - withoutTenantContext: sites.filter((s) => !s.carriesTenantContext).length, - tenancyEnabledWithoutTenantContext: tenancyEnabled.filter((s) => !s.carriesTenantContext).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, @@ -970,11 +988,12 @@ export const END_MARKER = '{/* END GENERATED: tenant-audit-census */}'; function aggregate(census) { const groups = new Map(); for (const site of census.sites) { - const posture = site.carriesTenantContext + const posture = site.carriesTenantContext === true ? (site.elevatedContext === true ? 'elevated' : site.elevatedContext === false ? 'tenant-scoped' : 'context, elevation undecidable') - : 'NONE'; + : 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); } @@ -997,8 +1016,11 @@ export function renderGeneratedRegion(census) { 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(`| carrying NO tenant context at all | **${t.withoutTenantContext}** |`); - out.push(`| …of those, against a decidably tenancy-enabled object | **${t.tenancyEnabledWithoutTenantContext}** |`); + 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} |`); @@ -1073,8 +1095,11 @@ export function renderCountsFile(census) { 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(`| Carrying no tenant context | ${t.withoutTenantContext} |`); - out.push(`| …and decidably tenancy-enabled | ${t.tenancyEnabledWithoutTenantContext} |`); + 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} |`); @@ -1184,6 +1209,41 @@ export function selfTest() { 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) { @@ -1192,7 +1252,8 @@ export function selfTest() { } 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`, and the ordinary verdicts).', + + '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; } @@ -1224,7 +1285,8 @@ function main(argv) { ` 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}`, - ` no tenant context ${t.withoutTenantContext} (tenancy-enabled: ${t.tenancyEnabledWithoutTenantContext})`, + ` 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}`, From 924d6ea41442feeaee3d534350e89c71f342d98b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 03:43:42 +0000 Subject: [PATCH 6/9] Route the census step's two self-tests through a collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-step-collectors` caught it: two independent self-tests as a bare sequence in one `run:` block means the second never runs under `bash -e` once the first fails — neither green nor red (#10814). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- .github/workflows/lint.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6b0671050e..b2d09062f5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1427,8 +1427,31 @@ jobs: # tracked sources plus the object registry, no spawns; ~8s. - name: Tenant-audit census matches the tree run: | - node scripts/tenant-audit-census.mjs --self-test - node scripts/check-tenant-audit-census.mjs --self-test + # Two INDEPENDENT self-tests, so they go through a collector rather + # than a bare sequence: under `bash -e` the first non-zero exit aborts + # the step and the second is never run — neither green nor red. The + # collector runs both unconditionally and still FAILS when either does, + # naming every one that failed. ⛔ Never let it swallow the exit code. + # Pinned by `node scripts/check-step-collectors.mjs`. + failed="" + run_self_test() { + echo "-- $*" + if "$@"; then + echo "PASS $*" + else + echo "FAIL $*" + failed="${failed} $*"$'\n' + fi + return 0 + } + run_self_test node scripts/tenant-audit-census.mjs --self-test + run_self_test node scripts/check-tenant-audit-census.mjs --self-test + if [ -n "$failed" ]; then + echo "" + echo "tenant-audit census self-tests — the following FAILED:" + printf "%s" "$failed" + exit 1 + fi node scripts/check-tenant-audit-census.mjs # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` From 29a69f248875062b11897fa17f6859e7847801f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:10:45 +0000 Subject: [PATCH 7/9] wip: unpromote generator --- .github/workflows/lint.yml | 26 +-------- .../docs/permissions/tenant-audit-census.mdx | 6 ++- scripts/check-tenant-audit-census.mjs | 54 +++++++++++++++++++ scripts/tenant-audit-census.mjs | 15 +++++- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b2d09062f5..058abbb24f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1427,31 +1427,7 @@ jobs: # tracked sources plus the object registry, no spawns; ~8s. - name: Tenant-audit census matches the tree run: | - # Two INDEPENDENT self-tests, so they go through a collector rather - # than a bare sequence: under `bash -e` the first non-zero exit aborts - # the step and the second is never run — neither green nor red. The - # collector runs both unconditionally and still FAILS when either does, - # naming every one that failed. ⛔ Never let it swallow the exit code. - # Pinned by `node scripts/check-step-collectors.mjs`. - failed="" - run_self_test() { - echo "-- $*" - if "$@"; then - echo "PASS $*" - else - echo "FAIL $*" - failed="${failed} $*"$'\n' - fi - return 0 - } - run_self_test node scripts/tenant-audit-census.mjs --self-test - run_self_test node scripts/check-tenant-audit-census.mjs --self-test - if [ -n "$failed" ]; then - echo "" - echo "tenant-audit census self-tests — the following FAILED:" - printf "%s" "$failed" - exit 1 - fi + 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` diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 04195e9e0c..fdbfb7d270 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -134,8 +134,10 @@ 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/tenant-audit-census.mjs ---self-test` pins all of it — 18 cases, 6 of which red against the old reading. +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 diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs index dabc348d16..9a95cfdeca 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -86,10 +86,51 @@ import { 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. * @@ -416,6 +457,19 @@ export function selfTest() { 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 ' diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index b2a110358a..87a2af9a30 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -7,6 +7,17 @@ * * 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 @@ -1135,7 +1146,8 @@ export function spliceRegion(pageText, region) { } // --------------------------------------------------------------------------- -// Self-test -- the only instrument on the elevation classifier +// 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). // --------------------------------------------------------------------------- /** @@ -1259,7 +1271,6 @@ export function selfTest() { } function main(argv) { - if (argv.includes('--self-test')) return selfTest(); const c = runCensus(); if (argv.includes('--write')) { From fc8858a24beb4c9aed019a3606f97ab29df7c5b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:30:50 +0000 Subject: [PATCH 8/9] Keep the census generator a followable module, not a gate file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch-gates --self-test` went red on this branch, by name: ✗ promoting 11 module(s) to gate files subtracts no inherited hint from any other family — LOST: scripts/check-tenant-audit-census.mjs <- scripts/tenant-audit-census.mjs (packages/services, packages/plugins, …) Wiring `node scripts/tenant-audit-census.mjs --self-test` into lint.yml made the generator a GATE FILE, and `discoverFamilies` refuses to follow one. The gate spelled ZERO path literals of its own and inherited all 22 from that module, so the promotion cut every one — a PR touching `packages/services/**`, the exact population this census counts, would silently stop being told this gate reads its diff. A lead that stops appearing is indistinguishable from one never earned. Fixed at the wiring rather than by weakening the assertion. The generator drops its `--self-test` FLAG and keeps its `selfTest()`, which the gate's own `--self-test` now drives — so the 18 classifier cases still run on every CI pass while the module stays followable. lint.yml goes back to two commands, so the collector this step briefly needed is no longer one. Belt and braces: the gate now carries a `ROOT_DIR_WATCH_HINTS` provenance declaration naming the two corpus subtrees and both artefacts, so the next attempt to invoke the generator directly cannot silently cut discoverability again. Family hints: 0 -> 24. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC From 82025f57cc36a02531067d32abaf4b1ead8df159 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:25:11 +0000 Subject: [PATCH 9/9] Split the tenant-audit census gate into enforced population and unenforced corpus scale Maintainer ruling 2026-08-31 (option A): the census gate adopts the enforced/unenforced split the sibling `check-system-context-census.mjs` already proved. Population -- every write call site and its tenancy/context verdict -- stays enforced byte-for-byte. Corpus scale (sources scanned and its kin) becomes present, dated, and not compared. The sibling's mechanism does NOT transplant: it splits a list of regexes over a hand-written page, while this gate's enforced half is a byte comparison of generated text with no per-number comparison to drop. So the scale values are masked out of both sides of that comparison, and required to be present and dated instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- .github/workflows/lint.yml | 7 +- .../docs/permissions/tenant-audit-census.mdx | 33 +- ...08-tenant-audit-write-call-sites.counts.md | 22 +- scripts/check-tenant-audit-census.mjs | 305 +++++++++++++++++- scripts/tenant-audit-census.mjs | 110 ++++++- 5 files changed, 438 insertions(+), 39 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 058abbb24f..993f70a8e2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1423,8 +1423,11 @@ jobs: # 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 534 - # tracked sources plus the object registry, no spawns; ~8s. + # 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 diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index fdbfb7d270..61bb8254ae 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -84,9 +84,9 @@ 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, exactly two opt out -(`sys_api_key`, `sys_sso_provider`), and no write call site on this surface -targets either. +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 @@ -209,11 +209,28 @@ cannot read, and they are neither in nor out. | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 53 | -Scanned 534 tracked non-test sources under `packages/services/` and -`packages/plugins/`, against 56 engine-shaped types and -297 declared objects. 119 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), +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 index 4154735434..7706b6adc2 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -42,10 +42,24 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Threading a decidably elevated context | 97 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 101 | -| Sources scanned | 534 | -| Engine-shaped types recognised | 56 | -| Declared objects in the registry | 297 | -| Same-named calls subtracted as non-engine | 119 | + +## 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 diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs index 9a95cfdeca..ef7c1fa047 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -53,13 +53,31 @@ * `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. + * 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. + * 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 @@ -83,6 +101,7 @@ import { COUNTS, END_MARKER, PAGE, + corpusScaleRows, renderCountsFile, renderGeneratedRegion, runCensus, @@ -162,11 +181,6 @@ export const PROSE_COUNTS = [ pattern: /and the (\d+) that name their object through a `const`/, expected: (c) => c.totals.objectNameConst, }, - { - name: 'declared objects in the registry', - pattern: /Across (\d+) declared objects/, - expected: (c) => c.declaredObjects, - }, { name: 'sites whose options argument is unreadable (limits section)', pattern: /\*\*(\d+) of the \d+ sites are spelled that way\*\*/, @@ -269,6 +283,155 @@ export const PROSE_COUNTS = [ }, ]; +// --------------------------------------------------------------------------- +// ⭐ 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); @@ -326,12 +489,40 @@ export function checkPage(census, pageText, countsText) { 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)], ]) { - if (committed === expected) continue; - const diff = firstDifference(committed ?? '', expected); + // ⛔ "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 @@ -372,6 +563,27 @@ export function checkPage(census, pageText, countsText) { } } + // ── 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; } @@ -424,9 +636,17 @@ export function selfTest() { 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(`Across ${census.declaredObjects} declared objects`, `Across ${census.declaredObjects - 1} declared objects`)) - .some((p) => p.startsWith('[prose-count]'))); + 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( @@ -438,8 +658,10 @@ export function selfTest() { // 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(`Across ${census.declaredObjects} declared objects`, 'Across many declared objects')) - .some((p) => p.startsWith('[prose-pattern-dead]'))); + 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( @@ -447,6 +669,59 @@ export function selfTest() { `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]'))); @@ -474,7 +749,9 @@ export function selfTest() { `✓ 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).', + + '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; } diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 87a2af9a30..806eb8c5ae 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -98,8 +98,11 @@ * * `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: 297 objects, of which exactly two - * (`sys_api_key`, `sys_sso_provider`) opt out. + * 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 * @@ -111,6 +114,23 @@ * 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 @@ -152,6 +172,30 @@ export function collectSources(root = ROOT, roots = SURFACE_ROOTS) { 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; @@ -956,6 +1000,7 @@ export function runCensus({ root = ROOT, roots = SURFACE_ROOTS } = {}) { engineTypes: index.size, declaredObjects: objects.size, scannedSources: sources.length, + measuredAt: measuredAt(root), }; } @@ -1015,6 +1060,50 @@ function aggregate(census) { || 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; @@ -1047,13 +1136,14 @@ export function renderGeneratedRegion(census) { 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(`Scanned ${census.scannedSources} tracked non-test sources under \`packages/services/\` and`); - out.push(`\`packages/plugins/\`, against ${census.engineTypes} engine-shaped types and`); - out.push(`${census.declaredObjects} declared objects. ${census.nonEngineCalls} calls to a same-named`); - out.push(`method on something that is not a data engine were subtracted. Every site is`); - out.push(`listed in [\`${COUNTS}\`](https://github.com/objectstack-ai/objectstack/blob/main/${COUNTS}),`); + 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'); } @@ -1114,10 +1204,8 @@ export function renderCountsFile(census) { 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(`| Sources scanned | ${census.scannedSources} |`); - out.push(`| Engine-shaped types recognised | ${census.engineTypes} |`); - out.push(`| Declared objects in the registry | ${census.declaredObjects} |`); - out.push(`| Same-named calls subtracted as non-engine | ${census.nonEngineCalls} |`); + out.push(''); + out.push(...renderCorpusScale(census, '##')); out.push(''); out.push('## Every site'); out.push('');