diff --git a/.changeset/18211-org-scoping-engine-named-receiver.md b/.changeset/18211-org-scoping-engine-named-receiver.md new file mode 100644 index 00000000000..83ef95ade22 --- /dev/null +++ b/.changeset/18211-org-scoping-engine-named-receiver.md @@ -0,0 +1,15 @@ +--- +"@objectstack/organizations": minor +--- + +`claimOrphanOrgRows` and `claimOrgSeedOwnership` name the ObjectQL doors they write through — a package-private `OrgScopingEngine` interface replaces `ql: any` on both, and `OrgScopingQuerySlot` states the doors the plugin forwards rather than only the three it calls itself (#18211). + +The package's public entry is unchanged: `src/index.ts` exports exactly the nine names it exported before, byte for byte. What moved on the published surface is the two exported functions' signatures, and nothing else. + +Runtime behaviour is unchanged: the same guards run, the same rows are updated, and an engine without a `registry` still returns `[]` with a warning instead of throwing — `registry` is optional on the new type precisely so that tested path stays describable. + +- **Why a type and not a comment.** The tenant-audit census decides whether a write call site is an engine write by reading the **receiver's declared type**. An `any` receiver has no type to read, so both of these sites were reported as sites nothing could place — an error in that census, never a default, because a write it cannot see is a write the tenant-audit population does not certify. Naming the doors places both by type. The certified population moves 223 to 225 and both read as elevated (they write under `context: SYSTEM_CTX`). +- **Narrow on purpose**, following `OrphanCleanupEngine` in `@objectstack/plugin-sharing`: `OrgScopingEngine` declares `find`, `update` and an optional `registry`, and nothing else. Widen it by adding a door that is actually used, never by re-exporting the engine's full contract — and keep it package-private: the census reads the type declared at the receiver, never the package entry, so exporting it would widen a published surface and buy the fix nothing. +- **The slot change is a finding, not a refactor.** `OrgScopingQuerySlot` declared `registerMiddleware`, `find` and `getSchema` — but the plugin also hands that value to `claimOrphanOrgRows`, which writes through it. While the back-fill's parameter was `any` that coupling was invisible to the type system; naming the parameter turned it into a type error, and the slot now states it. +- **Type-level tightening for consumers.** A caller passing a value that does not structurally offer `find` and `update` no longer compiles. Such a caller already got `[]` and a warning at run time from the existing guards, so nothing that worked stops working — but the failure moves from run time to build time, which is why this is not a patch. The parameter type is inlined into the emitted declarations, so a consumer never needs to name it. +- ⛔ **No `UNTYPED_RECEIVERS` ledger row was added.** That ledger is documented shrink-only and keyed by (file, receiver); growing it by two rows to silence two sites runs against its own discipline, and a typed receiver needs no row at all. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 9a4dd57c443..6a42541c60c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -106,7 +106,7 @@ 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 223 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 225 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. @@ -155,10 +155,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 | **223** | +| 175 write call sites | quoted in the merged changeset | **225** | | 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 | **149 of 223** decidable, **74** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 104 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **149 of 225** decidable, **76** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 106 decidably elevated, 0 decidably not, 102 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 @@ -175,11 +175,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -104 of 223 (47%) as decidably elevated, with 102 more whose elevation is a +106 of 225 (47%) as decidably elevated, with 102 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 / 223`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 225`, 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 @@ -191,23 +191,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **223** | +| write call sites on the application surface | **225** | | …whose object name is statically decidable | 149 | -| …whose object name is chosen at run time | 74 | +| …whose object name is chosen at run time | 76 | | …against an object with tenancy ENABLED | 149 | | …against an object that declares tenancy off | 0 | -| threading a tenant context | 139 | +| threading a tenant context | 141 | | 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 | 104 | +| threading a decidably ELEVATED (`isSystem`) context | 106 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 179 | +| receiver carried a readable engine type | 181 | | receiver erased, placed by the object NAME | 18 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 | @@ -215,7 +215,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 109 | | object name spelled through a `const` | 40 | | object name is an `object: string` parameter | 19 | -| object name is some other run-time expression | 55 | +| object name is some other run-time expression | 57 | 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 @@ -232,13 +232,13 @@ 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-09-14 at `d4554d4f5`. +Measured on 2026-09-16 at `11daf7f69`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 568 | -| engine-shaped types recognised | 61 | +| tracked non-test sources scanned | 570 | +| engine-shaped types recognised | 63 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 140 | +| same-named calls subtracted as non-engine | 146 | {/* 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 8732a773be7..69505a01584 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,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 223 | +| Write call sites | 225 | | Object name statically decidable | 149 | -| Object name chosen at run time | 74 | +| Object name chosen at run time | 76 | | Against a tenancy-enabled object | 149 | | Against an object declaring tenancy off | 0 | -| Threading a tenant context | 139 | +| Threading a tenant context | 141 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 104 | +| Threading a decidably elevated context | 106 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -52,19 +52,21 @@ 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-09-14 at `d4554d4f5`. +Measured on 2026-09-16 at `11daf7f69`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 568 | -| engine-shaped types recognised | 61 | +| tracked non-test sources scanned | 570 | +| engine-shaped types recognised | 63 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 140 | +| same-named calls subtracted as non-engine | 146 | ## Every site | file | verb | object | tenancy | tenant context | n | |---|---|---|---|---|---:| +| `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | +| `packages/plugins/organizations/src/claim-orphan-org-rows.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `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 | 14 | | `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 | diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts index 3b8d03f67a5..323ec61dd54 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -23,6 +23,8 @@ import type { ServiceObject } from '@objectstack/spec/data'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; + interface ClaimOwnershipOptions { logger?: { info: (message: string, meta?: Record) => void; @@ -49,7 +51,7 @@ function hasField(schema: ServiceObject, field: string): boolean { * and updates the org's unowned rows as `isSystem`. Returns a per-object summary. */ export async function claimOrgSeedOwnership( - ql: any, + ql: OrgScopingEngine, organizationId: string, ownerUserId: string, options: ClaimOwnershipOptions = {}, @@ -57,7 +59,7 @@ export async function claimOrgSeedOwnership( const logger = options.logger; if (!organizationId || !ownerUserId) return []; if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return []; - const registry = (ql as any).registry; + const registry = ql.registry; if (!registry || typeof registry.getAllObjects !== 'function') { logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable'); return []; diff --git a/packages/plugins/organizations/src/claim-orphan-org-rows.ts b/packages/plugins/organizations/src/claim-orphan-org-rows.ts index 08eb36418c7..6af4cc3d277 100644 --- a/packages/plugins/organizations/src/claim-orphan-org-rows.ts +++ b/packages/plugins/organizations/src/claim-orphan-org-rows.ts @@ -28,6 +28,8 @@ import type { ServiceObject } from '@objectstack/spec/data'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; + interface ClaimOptions { logger?: { info: (message: string, meta?: Record) => void; @@ -58,7 +60,7 @@ function hasOrganizationField(schema: ServiceObject): boolean { * Returns a per-object summary `{ object, count }[]`. */ export async function claimOrphanOrgRows( - ql: any, + ql: OrgScopingEngine, organizationId: string, options: ClaimOptions = {}, ): Promise<{ object: string; count: number }[]> { @@ -66,7 +68,7 @@ export async function claimOrphanOrgRows( if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') { return []; } - const registry = (ql as any).registry; + const registry = ql.registry; if (!registry || typeof registry.getAllObjects !== 'function') { logger?.warn?.('[org-scoping] claimOrphanOrgRows: registry unavailable'); return []; diff --git a/packages/plugins/organizations/src/org-scoping-engine.ts b/packages/plugins/organizations/src/org-scoping-engine.ts new file mode 100644 index 00000000000..df75a7dcb7a --- /dev/null +++ b/packages/plugins/organizations/src/org-scoping-engine.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ObjectQL doors the org-scoping back-fills reach through, named instead of + * erased. + * + * Both back-fills (`claimOrphanOrgRows`, `claimOrgSeedOwnership`) used to take + * `ql: any`. That is not a style preference in this corpus: the tenant-audit + * census reads the RECEIVER's declared type to decide whether a write call site + * is an engine write at all, and an `any` receiver has no type to read. Sites it + * cannot place are reported as `unledgered` -- an error, never a default, + * because a write it cannot see is a write the tenant-audit population does not + * certify. Naming the doors here places both sites by their TYPE, which is the + * one placement route that needs no ledger row. + * + * ⛔ Deliberately narrow, following `OrphanCleanupEngine` in `plugin-sharing`: + * it declares only what these two functions call, so it cannot drift into a + * second, competing description of the whole engine. Widen it by adding the door + * you actually use, never by re-exporting the engine interface. + * + * ⛔ And deliberately PACKAGE-PRIVATE -- the same restraint one layer out. The + * census reads the type declared at the RECEIVER, in this source tree; it never + * reads the package's public entry, so exporting this bought the placement + * nothing and only widened a published surface. ⛔ Do not add it to `index.ts`. + */ + +import type { ServiceObject } from '@objectstack/spec/data'; + +export interface OrgScopingEngine { + find(object: string, query: any, options?: any): Promise; + update(object: string, data: any, options?: any): Promise; + /** + * Optional on purpose. "registry unavailable" is a real, tested, logged no-op + * path in both back-fills -- a caller handing over an engine without one gets + * an empty result and a warning, not a throw -- so the type must be able to + * describe that engine rather than forcing the guard to be dead code. + */ + registry?: { getAllObjects(): ServiceObject[] }; +} diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 62c07176c8e..26cbdd31e3b 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -2,6 +2,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; +import type { OrgScopingEngine } from './org-scoping-engine.js'; import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; @@ -66,13 +67,18 @@ export interface OrganizationsPluginOptions { * repository types its lookups rather than inheriting a grandfather clause it is * not on. Structural rather than the engine's full contract for the same reason * `membership-policy-gate.ts` states about ITS probes: this plugin needs three - * members, the `catch` arms below already treat every one of them as possibly - * absent, and naming the whole engine interface here would claim a coupling the - * runtime checks do not make. + * members, and the `catch` arms below already treat every one of them as + * possibly absent. + * + * It extends `OrgScopingEngine` because this plugin does not only CALL the slot, + * it FORWARDS it: `claimOrphanOrgRows(ql, ...)` below writes through this very + * value. While that parameter was `any` the forwarded doors were a coupling the + * types did not state and the tenant-audit census could not read. Naming them + * here is the narrow claim -- only the doors that are actually forwarded, not + * the engine's full contract. */ -interface OrgScopingQuerySlot { +interface OrgScopingQuerySlot extends OrgScopingEngine { registerMiddleware(mw: (opCtx: any, next: () => Promise) => Promise): void; - find(object: string, query: unknown, options?: unknown): Promise; getSchema?(object: string): any; } diff --git a/scripts/check-tenant-audit-census.mjs b/scripts/check-tenant-audit-census.mjs index 7789eb3791c..e5102459e1c 100644 --- a/scripts/check-tenant-audit-census.mjs +++ b/scripts/check-tenant-audit-census.mjs @@ -45,7 +45,7 @@ * 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 + * ## The checks * * A DRIFT both generated artefacts equal what the generator produces * from the tree right now, byte-for-byte: the page's generated @@ -64,6 +64,15 @@ * are hand-written, and a hand-written number is exactly the * thing that goes stale first. Its corpus-scale counterpart is * `UNENFORCED_PROSE_COUNTS`: required to be said, not to be right. + * C CENSUS the census this gate just ran placed every write call site it + * found, and every `UNTYPED_RECEIVERS` row still matches a call. + * Those are facts about the TREE rather than about the artefacts + * and they arrive on the census object itself, as `unledgered` + * and `staleLedgerRows`. CI runs THIS gate and never the + * generator, so until check C existed the generator's own exit 1 + * on an unplaceable receiver reached nobody: the census could + * find one, say so to no one, and `Lint & Repo Gates` stayed + * green. See `censusRefusals`. * * ## ⭐ What is enforced, and what is deliberately not * @@ -89,7 +98,9 @@ * 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. + * are all exit 1 naming what could not be read. The first two of those reach the + * exit code through `censusRefusals` (check C); the third throws out of + * `runCensus()` before any check runs. */ import { readFileSync } from 'node:fs'; @@ -125,12 +136,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'A DRIFT': 5, 'B PROSE': 4, '⭐ THE SPLIT': 9, + 'census refusals': 5, 'refusals': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 4; +const SELF_TEST_BATTERY_FLOOR = 5; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -573,7 +585,62 @@ function firstDifference(committed, expected) { } /** - * Run both checks against a census and a page text. + * ⭐ Check C -- the refusals the CENSUS raises, given an exit. + * + * `runCensus()` reports two failures about the TREE rather than about the + * artefacts: a write call site whose receiver is erased and that none of the + * three placement rules reaches (`census.unledgered`), and an `UNTYPED_RECEIVERS` + * row that no longer matches any call in the corpus (`census.staleLedgerRows`). + * + * The generator's own `main()` prints both and exits 1 -- but `lint.yml` invokes + * THIS gate and never the generator, so until this function existed those two + * fields were read by nothing on the way to a CI verdict. The census could find + * an unplaceable receiver, print nothing anyone runs, and the job stayed green: + * a defect arriving as COMPLIANCE, which is the one direction the census says it + * cannot survive being wrong in (`tenant-audit-census.mjs`, the rule the page + * publishes verbatim: *"An unreadable receiver that none of the three place is an + * ERROR, never a default."*). + * + * ⛔ The repair is NOT to add `node scripts/tenant-audit-census.mjs` beside this + * gate in the workflow: that walks the same corpus and builds the same AST a + * second time for one verdict this gate already holds in its hand. What was + * missing is the READ, and the read costs nothing. + * + * ⚠️ Deliberately NOT folded into `checkPage`. That function certifies the two + * ARTEFACTS against a census and takes its page as text so the self-test can feed + * it adversarial fixtures; this one certifies the CENSUS, and its fixtures are + * synthetic censuses. Keeping them apart is what lets the self-test instrument + * check C in both directions no matter what today's tree happens to hold -- a + * tree with an unplaceable receiver would otherwise turn `--self-test` red on its + * "the committed artefacts are clean" case and take every other case's verdict + * with it. + * + * @returns {string[]} problems, empty when the census placed every site it found. + */ +export function censusRefusals(census) { + const problems = []; + for (const u of census.unledgered ?? []) { + problems.push( + `[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. Nothing ` + + 'places this site, so the census cannot say whether it is an engine write at all and the ' + + 'page certifies a population with a hole in it. Rule on what the receiver is, then add an ' + + '`UNTYPED_RECEIVERS` row in `scripts/tenant-audit-census.mjs` saying so.', + ); + } + for (const r of census.staleLedgerRows ?? []) { + problems.push( + `[stale-ledger-row] UNTYPED_RECEIVERS names ${r.file} (receiver \`${r.receiver}\`) but no ` + + 'such write call exists -- delete the row. A row that matches nothing still EXCUSES the ' + + 'next site that grows into its (file, receiver) pair, so a stale row is an exemption ' + + 'nobody can see.', + ); + } + return problems; +} + +/** + * Run both artefact 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. @@ -863,6 +930,48 @@ export function selfTest() { check(page.replace(declaredObjectsSentence, 'Across the declared objects')) .some((p) => p.startsWith('[unenforced-prose-missing]'))); + // ── census refusals (check C) ────────────────────────────────────────────── + // ⭐ The direction the ARTEFACTS cannot show at all: these two fields are facts + // about the TREE, and a gate that never reads them prints a success line while + // the census it just ran says it could not place a site. Driven with SYNTHETIC + // censuses, so the rule stays instrumented whatever today's tree holds -- and + // in BOTH directions, because a `censusRefusals` that always returned `[]` + // would look identical to this self-test on a census that happens to be clean. + battery('census refusals'); + const unplaceableSite = { + file: 'packages/services/service-fixture/src/seed.ts', + line: 42, + receiver: 'ql', + verb: 'update', + how: 'ql:any', + detail: 'any', + ledgered: false, + }; + const orphanedRow = { + file: 'packages/plugins/plugin-fixture/src/departed.ts', + receiver: 'engine', + engine: true, + what: 'a ledger row whose write call left the tree', + }; + const refuse = (unledgered, staleLedgerRows) => + censusRefusals({ ...census, unledgered, staleLedgerRows }); + + t('a census that could not place a receiver is a finding', + refuse([unplaceableSite], []).some((p) => p.startsWith('[untyped-receiver]'))); + t('the unplaceable-receiver refusal names the site it could not place', + refuse([unplaceableSite], []).some((p) => p.includes(`${unplaceableSite.file}:${unplaceableSite.line}`))); + t('a ledger row matching nothing in the tree is a finding', + refuse([], [orphanedRow]).some((p) => p.startsWith('[stale-ledger-row]'))); + t('the stale-row refusal names the row it could not match', + refuse([], [orphanedRow]).some((p) => p.includes(orphanedRow.file) && p.includes(orphanedRow.receiver))); + + // ⛔ ...and the control, without which all four cases above are equally passed + // by a function that simply reports everything it is handed: a census that + // placed every site and matched every row is clean. + t('⭐ CONTROL: a census with neither an unplaceable site nor a stale row is NOT a finding', + refuse([], []).length === 0, + refuse([], []).join(' | ')); + // ── refusals ─────────────────────────────────────────────────────────────── battery('refusals'); t('an empty census refuses rather than certifying the artefacts', @@ -933,7 +1042,9 @@ function main(argv) { } const census = runCensus(); - const problems = checkPage(census, page, counts); + // ⭐ Check C first: `checkPage` certifies the artefacts AGAINST this census, so + // a census that could not place one of its own sites is the prior question. + const problems = [...censusRefusals(census), ...checkPage(census, page, counts)]; for (const p of problems) console.error(`::error::${p}`); if (problems.length > 0) { @@ -945,7 +1056,8 @@ function main(argv) { `✓ check-tenant-audit-census: OK -- ${t.writeCallSites} write call sites certified ` + `(${t.staticallyDecidableObjectName} decidable; ${t.tenancyEnabledProvablyNoContext} tenancy-enabled ` + `sites PROVABLY carry no tenant context, ${t.tenancyEnabledContextUnreadable} more unreadable), ` - + `${PROSE_COUNTS.length} prose figures held to the census.`, + + `${PROSE_COUNTS.length} prose figures held to the census, ` + + 'every write call site placed and every `UNTYPED_RECEIVERS` row matched.', ); return 0; }