|
| 1 | +--- |
| 2 | +title: Tenant-Audit Census |
| 3 | +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. |
| 4 | +--- |
| 5 | + |
| 6 | +`SqlDriver.auditMissingTenant()` is the platform's one *diagnostic* on write-side |
| 7 | +tenancy. It warns when a write lands on a tenancy-enabled object without |
| 8 | +`options.tenantId` — the shape of a seed, migration or background path that |
| 9 | +forgot to thread the acting session. |
| 10 | + |
| 11 | +This page is **the authority** for the population that control acts on: every |
| 12 | +write call site on the application surface, what object it targets, and what |
| 13 | +execution context it threads. The figures below are generated, not written — |
| 14 | +rebuilt from the tree by `scripts/tenant-audit-census.mjs`, and |
| 15 | +`scripts/check-tenant-audit-census.mjs` fails the build when what is committed |
| 16 | +and what the tree says disagree. |
| 17 | + |
| 18 | +The per-site rows live next door, in |
| 19 | +`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`, and are written by |
| 20 | +the same command. The split is the one `packages/spec`'s strictness ledger |
| 21 | +already uses and is there for the same reason: this page has prose worth |
| 22 | +preserving and the row table has none, so the table is regenerated whole while |
| 23 | +the page keeps a small generated region for the figures its prose reasons about. |
| 24 | + |
| 25 | +## ⚠️ Why this page exists at all |
| 26 | + |
| 27 | +The measurement this page carries used to live in **a comment on an issue**. That |
| 28 | +issue became unreachable — it returns 404 on unauthenticated REST, on the rendered |
| 29 | +web page and on authenticated API access alike, while its immediate neighbours |
| 30 | +answer 200 — and it took the census with it. Three open cards named that comment |
| 31 | +as their input, and nothing anywhere noticed that their input had gone. |
| 32 | + |
| 33 | +What survived did so by luck. A changeset author had happened to quote two of the |
| 34 | +figures in prose, so "175 write call sites, 24 of them carrying no tenant context" |
| 35 | +is still readable on `main` in |
| 36 | +`.changeset/tenant-audit-update-delete-half-repairs.md` — while the list of 24 |
| 37 | +that a downstream card was briefed to classify is not recoverable at all. |
| 38 | + |
| 39 | +⇒ A census that decides a repair family's severity and a ruling's scope is not a |
| 40 | +comment. It is a re-runnable instrument plus a committed page. Losing any issue |
| 41 | +now costs nothing, and the population is **re-derivable** rather than quoted. |
| 42 | + |
| 43 | +## What the control does, and what a call site controls |
| 44 | + |
| 45 | +`auditMissingTenant(object, op, options)` is gated in this order: |
| 46 | + |
| 47 | +1. `OS_TENANT_AUDIT=0` — off entirely; |
| 48 | +2. `options.bypassTenantAudit` — the engine sets this for **every** |
| 49 | + `ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`); |
| 50 | +3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`; |
| 51 | +4. the deployment posture — the warning is only meaningful on a walled install; |
| 52 | +5. the object having a tenant field. |
| 53 | + |
| 54 | +Both of the guards a *caller* can move are downstream of one decision at the call |
| 55 | +site: **whether it threads an execution context at all**. Thread an elevated one |
| 56 | +and guard 2 silences the warning; thread a tenant-bearing one and guard 3 does; |
| 57 | +thread none and the site is what this census counts. |
| 58 | + |
| 59 | +## How the population is decided |
| 60 | + |
| 61 | +A site is a call to one of the three `IDataEngine` write doors — `insert`, |
| 62 | +`update`, `delete` — on a receiver whose declared type is an engine, in tracked |
| 63 | +non-test sources under `packages/services/` and `packages/plugins/`. |
| 64 | + |
| 65 | +**The verb name is not the signal.** `.delete()` alone answers some 250 call sites |
| 66 | +in this corpus, and most of them are `Map.delete`, `Set.delete`, `Headers.delete`, |
| 67 | +a crypto `Hash.update`, a blob-storage delete-by-key and a search-index de-index. |
| 68 | +So the receiver is typed structurally: a declared interface or type literal counts |
| 69 | +as an engine when it declares a write door whose first parameter is named |
| 70 | +`object` / `objectName` / `objectApiName` / `name` and typed `string` — the |
| 71 | +`IDataEngine` door signature. Interfaces that extend one inherit it; aliases that |
| 72 | +narrow one (`Partial<Pick<IDataEngine, …>>`) carry it. |
| 73 | + |
| 74 | +**The expensive failure direction is a keyword.** Sites whose receiver the author |
| 75 | +typed `any` have no type to read, and there are 45 of them — better than a fifth |
| 76 | +of the population, concentrated in exactly the seed and bootstrap paths this |
| 77 | +control exists for. Scoring an unreadable receiver as "not an engine" would have |
| 78 | +dropped every one of them silently, with a clean exit and a smaller number that |
| 79 | +reads exactly like a smaller truth. So `any` is not a classification here: it goes |
| 80 | +to the unreadable pile, and the unreadable pile is placed by facts about the tree |
| 81 | +(the argument names a declared object; the argument is a parameter declared |
| 82 | +`object: string`) or by a written `UNTYPED_RECEIVERS` row. **An unreadable |
| 83 | +receiver that none of the three place is an error, never a default.** |
| 84 | + |
| 85 | +Tenancy itself is enabled *by default* — `isTenancyDisabled()` reads |
| 86 | +`tenancy.enabled === false` and nothing else — so the object registry only has to |
| 87 | +find the opt-outs. Across 297 declared objects — the dated, ⛔ unenforced |
| 88 | +corpus-scale figure below — exactly two opt out (`sys_api_key`, |
| 89 | +`sys_sso_provider`), and no write call site on this surface targets either. |
| 90 | + |
| 91 | +## ⛔ What this instrument cannot decide |
| 92 | + |
| 93 | +A site whose object name is an inline literal or a local `const` is statically |
| 94 | +decidable. A site whose name is a parameter or a field — `ql.insert(object, …)` |
| 95 | +inside a shared seed helper, `this.objectName` on a per-object service — is not, |
| 96 | +and no amount of AST work makes it so: the object is chosen at run time. Those |
| 97 | +are reported as `undecidable` rather than assumed either way. |
| 98 | + |
| 99 | +The same holds twice over for the context. An options argument spelled as a |
| 100 | +literal can be read; one spelled `options`, `{ ...opts }`, or handed through a |
| 101 | +forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A |
| 102 | +context resolved from an inline literal or a local `const` can be tested for |
| 103 | +`isSystem`; one arriving from a helper call cannot. |
| 104 | + |
| 105 | +⇒ **A third of this population is undecidable in one dimension or another, and |
| 106 | +that is reported rather than smoothed over.** A census that quietly guessed on |
| 107 | +those sites would publish a number in the one direction this page cannot survive |
| 108 | +— claiming completeness while short — and a gate seeded from it would then hold |
| 109 | +the page to it. |
| 110 | + |
| 111 | +## ⚠️ Two over-claims this instrument made, and what they cost |
| 112 | + |
| 113 | +Both were found by the drift gate firing on this page's own artifact, and both |
| 114 | +are recorded here rather than quietly corrected, because the numbers they moved |
| 115 | +were published first. |
| 116 | + |
| 117 | +**The elevation classifier read a spread as a denial.** It walked an object |
| 118 | +literal's named properties looking for `isSystem` and returned "not elevated" |
| 119 | +when the loop ended — so `{ ...SYSTEM_CTX }` was scored **decidably NOT |
| 120 | +elevated** while every `SYSTEM_CTX` in the tree is `{ isSystem: true, … }`. It |
| 121 | +also never unwrapped `as const`, so a bare `context: SYSTEM_CTX` read as |
| 122 | +undecidable. Fixing both moved **51 sites' verdicts**: 45 from undecidable to |
| 123 | +elevated, and 6 from *decidably not elevated* to *elevated* — an inversion, on a |
| 124 | +security-relevant classification. The count of decidably-not-elevated sites is |
| 125 | +now **0**: nothing on this surface threads a context that provably lacks the flag. |
| 126 | + |
| 127 | +**"No tenant context" counted sites it had not read.** An options argument the |
| 128 | +walker could not parse was folded into the same bucket as one it had read and |
| 129 | +found empty. That published **84 sites "carrying no tenant context at all"** |
| 130 | +when 17 said so and 67 were simply unread — an over-claim in the *alarming* |
| 131 | +direction, on the very figure this page tells other cards to cite. `carries` is |
| 132 | +now three-valued, and an unreadable argument can never contribute to the |
| 133 | +provable count. |
| 134 | + |
| 135 | +⭐ Both are the same shape as the failure this artefact exists for, wearing |
| 136 | +opposite hats: one scored an unread thing as *nothing to report*, the other |
| 137 | +scored an unread thing as *a finding*. `node |
| 138 | +scripts/check-tenant-audit-census.mjs --self-test` pins all of it — 18 classifier |
| 139 | +cases, 6 of which red against the old reading, driven from the gate's own |
| 140 | +self-test rather than from a flag on the generator. |
| 141 | + |
| 142 | +## ⭐ Deviations from the carried figures, untrimmed |
| 143 | + |
| 144 | +Four figures were carried forward from the lost census. They are **control inputs |
| 145 | +for this re-derivation, not targets**, and this instrument was not shaped to |
| 146 | +reproduce them. Where it disagrees, it disagrees on the page: |
| 147 | + |
| 148 | +| carried figure | where it survives | this census | |
| 149 | +| :--- | :--- | ---: | |
| 150 | +| 175 write call sites | quoted in the merged changeset | **215** | |
| 151 | +| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable | |
| 152 | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable | |
| 153 | +| 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 | |
| 154 | +| 141 and 132, two independent re-derivations | the card that filed this work | — | |
| 155 | + |
| 156 | +**The differences are not reconciled, and deliberately so.** The old census's |
| 157 | +population definition is not recoverable — that is the whole reason this page |
| 158 | +exists — so no honest reading can attribute the gap to a specific cause. What can |
| 159 | +be stated is what this instrument counts, which is written above and re-runnable |
| 160 | +at any commit. |
| 161 | + |
| 162 | +Two structural facts do plausibly widen this reading against any hand or regex |
| 163 | +one, and both are counted in the generated tables below: the 45 sites reached |
| 164 | +through an erased (`any`) receiver, and the 37 that name their object through a |
| 165 | +`const` rather than inline. An instrument that read either the way a person does |
| 166 | +would report a smaller number and would not say so. |
| 167 | + |
| 168 | +The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% |
| 169 | +figure has no surviving corroboration anywhere in the tree.** This census reads |
| 170 | +97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a |
| 171 | +run-time fact — so the claim is neither confirmed nor refuted, and the honest |
| 172 | +answer is that a static reading cannot settle it. |
| 173 | + |
| 174 | +⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was |
| 175 | +READ and holds no tenant context, against a decidably tenancy-enabled object. |
| 176 | +That is the control's provable yield surface. ⛔ Do not cite it as "the sites |
| 177 | +without tenant context" — **32 further sites** have an options argument this |
| 178 | +cannot read, and they are neither in nor out. |
| 179 | + |
| 180 | +{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} |
| 181 | + |
| 182 | +## The measurement |
| 183 | + |
| 184 | +| what | count | |
| 185 | +| :--- | ---: | |
| 186 | +| write call sites on the application surface | **215** | |
| 187 | +| …whose object name is statically decidable | 143 | |
| 188 | +| …whose object name is chosen at run time | 72 | |
| 189 | +| …against an object with tenancy ENABLED | 143 | |
| 190 | +| …against an object that declares tenancy off | 0 | |
| 191 | +| threading a tenant context | 131 | |
| 192 | +| PROVABLY carrying none (options read, no context key) | **17** | |
| 193 | +| …of those, against a decidably tenancy-enabled object | **9** | |
| 194 | +| options argument UNREADABLE — may or may not carry one | 67 | |
| 195 | +| …of those, against a decidably tenancy-enabled object | 32 | |
| 196 | +| threading a decidably ELEVATED (`isSystem`) context | 97 | |
| 197 | +| threading a context that is decidably NOT elevated | 0 | |
| 198 | +| threading a context whose elevation is a run-time fact | 101 | |
| 199 | + |
| 200 | +| how the instrument reached the site | count | |
| 201 | +| :--- | ---: | |
| 202 | +| receiver carried a readable engine type | 170 | |
| 203 | +| receiver erased, placed by the object NAME | 19 | |
| 204 | +| receiver erased, placed by an `object: string` PARAMETER | 15 | |
| 205 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 | |
| 206 | + |
| 207 | +| object name spelled inline | 106 | |
| 208 | +| object name spelled through a `const` | 37 | |
| 209 | +| object name is an `object: string` parameter | 19 | |
| 210 | +| object name is some other run-time expression | 53 | |
| 211 | + |
| 212 | +The corpus walked is every tracked non-test source under `packages/services/` |
| 213 | +and `packages/plugins/`; calls to a same-named method on something that is not |
| 214 | +a data engine were subtracted. Every site is listed in |
| 215 | +[`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), |
| 216 | +regenerated by the same command. |
| 217 | + |
| 218 | +### Corpus scale — present and dated, ⛔ NOT enforced |
| 219 | + |
| 220 | +⛔ These four describe the CORPUS this census walked, not the population it |
| 221 | +certifies, and the gate deliberately does not hold them to the tree — a source |
| 222 | +file arriving anywhere under the two roots moves them while every verdict above |
| 223 | +holds still. They are required to be HERE and to say WHEN they were true; |
| 224 | +their values are not compared. The reasoning, and the measurement behind it, |
| 225 | +are in `scripts/check-tenant-audit-census.mjs`. |
| 226 | + |
| 227 | +Measured on 2026-08-31 at `fc8858a24`. |
| 228 | + |
| 229 | +| corpus scale (not enforced) | count | |
| 230 | +| :--- | ---: | |
| 231 | +| tracked non-test sources scanned | 534 | |
| 232 | +| engine-shaped types recognised | 56 | |
| 233 | +| declared objects in the registry | 297 | |
| 234 | +| same-named calls subtracted as non-engine | 119 | |
| 235 | + |
| 236 | +{/* END GENERATED: tenant-audit-census */} |
0 commit comments