fix(lint): give the tenant-audit census refusals an exit to CI - #18534
Conversation
`runCensus()` reports two failures about the tree rather than about the artefacts -- `unledgered` (a write call site whose receiver is erased and that none of the three placement rules reaches) and `staleLedgerRows` (an `UNTYPED_RECEIVERS` row that matches no call). The generator's own `main()` prints both and exits 1, but `lint.yml` invokes the GATE and never the generator, and the gate read neither field. The census could find an unplaceable receiver, print nothing anyone runs, and `Lint & Repo Gates` stayed green -- a defect arriving as compliance, in the one direction the census and the published page both say is an error and never a default. Add check C, `censusRefusals()`, reading those two fields off the census the gate already holds. No second walk of the corpus: the read costs nothing and the fix stays inside the gate file. Kept out of `checkPage()` so the self-test can drive it with synthetic censuses in both directions regardless of what today's tree holds, with a control that a census placing every site is clean. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…s reach through `claimOrphanOrgRows` and `claimOrgSeedOwnership` both took `ql: any`, so the tenant-audit census had no receiver type to read and reported both write call sites as unledgered -- an error, never a default. Declare the narrow `OrgScopingEngine` interface these two functions actually call (following `OrphanCleanupEngine` in plugin-sharing), annotate both parameters with it, and drop the two `(ql as any).registry` casts it makes unnecessary. Placing the sites by TYPE needs no `UNTYPED_RECEIVERS` row: that ledger is documented shrink-only and keyed by (file, receiver), so growing it by two rows runs against its own discipline. `scripts/tenant-audit-census.mjs` is untouched. The certified population moves 223 to 225 and both sites read as elevated (`context: SYSTEM_CTX`), so the generated region and the audit ledger are regenerated with `--write` and the page's eight hand-written prose figures are restated to match. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e5e8cb016fd10965705136c991b5557772cd278b && git checkout e5e8cb016fd10965705136c991b5557772cd278b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 582d3e54ea3d0db31c56b5c624cd3195ef5043ee 006f8f3ab7fb5ae3e0ae9ca8632628c43281a751 && git checkout -B drift-repro 582d3e54ea3d0db31c56b5c624cd3195ef5043ee && git merge --no-ff 006f8f3ab7fb5ae3e0ae9ca8632628c43281a751
node scripts/docs-audit/affected-docs.mjs --json 582d3e54ea3d0db31c56b5c624cd3195ef5043ee |
… back-fills `OrgScopingQuerySlot` named only the three members the plugin calls itself, but it also FORWARDS the slot: `claimOrphanOrgRows(ql, ...)` writes through that very value. While the back-fill's parameter was `any` that coupling was invisible to the types; with the parameter named it is a type error, which is the finding rather than an obstacle. Extend the slot from `OrgScopingEngine` so it states the doors it forwards, and require `query` on the engine's `find` so the slot's own stricter spelling satisfies it. Regenerate the census artefacts: the recognised engine-shaped type count moves 61 to 63 (corpus scale, dated and unenforced); the certified population is unchanged at 225. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
The diff now moves published content: `@objectstack/organizations` ships `dist/` in its `files[]`, and `dist/index.d.ts` carries the narrowed `claimOrphanOrgRows(ql: OrgScopingEngine, ...)` signature plus the newly exported type. `skip-changeset` no longer applies. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
…vate `index.ts` re-exported `OrgScopingEngine`, which widened the package's published surface for no gain: the tenant-audit census reads the type declared at the RECEIVER in this source tree and never the package entry, so the export bought the placement nothing. Removing it leaves the whole diff a narrowing. Measured after removal: the census still reports 225 write call sites with zero unresolved receivers, the gate still exits 0, and both regenerated artefacts are byte-unchanged. The emitted declarations still carry the interface inline, so a consumer's call resolves without ever naming it. The engine module now records the visibility choice next to the narrowness one, so the export is not re-added as a convenience. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18211
Clause-②: no
What
Two halves, and the second is what makes the first landable.
scripts/check-tenant-audit-census.mjsgains check C,censusRefusals()— it readscensus.unledgeredandcensus.staleLedgerRowsoff the census it already runs and routesboth to the gate's exit code.
claimOrphanOrgRowsand
claimOrgSeedOwnershiptookql: any, so they are given the narrowOrgScopingEnginetype they actually call through.
Why check C
runCensus()reports two failures about the tree rather than about the artefacts:unledgered(a write call site whose receiver is erased and that none of the three placementrules reaches) and
staleLedgerRows(anUNTYPED_RECEIVERSrow matching no call).The generator's own
main()prints both and exits 1. Butlint.yml(lines 1995/1996 on thistree) invokes the gate, never the generator — and the gate read neither field. Measured on
this branch's base
1e496f979:unledgeredreadsstaleLedgerRowsreadswriteCallSitesreadsscripts/check-tenant-audit-census.mjsscripts/tenant-audit-census.mjsThe control is counted in the same file as each zero, so those zeros are readings, not a grep
that failed to fire.
So the census could find an unplaceable receiver, say so to nobody, and
Lint & Repo Gatesstayed green — a defect arriving as compliance. This direction is published:
content/docs/permissions/tenant-audit-census.mdxtells readers that a receiver none of thethree place is an error, never a default.
The fix is the read, not a second run of the generator in the workflow: that would walk
the same corpus and build the same AST twice for one verdict the gate already holds in hand.
.github/workflows/**is untouched.Why the receivers are typed, and not ledgered
Check C found exactly two unplaced sites, both
ql: anyseed/back-fill helpers writingschema.nameundercontext: SYSTEM_CTX:packages/plugins/organizations/src/claim-org-seed-ownership.ts(:52the parameter,:93the write)packages/plugins/organizations/src/claim-orphan-org-rows.ts(:61and:106)Both match the already-ledgered
plugin-security/src/claim-seed-ownership.tsrow word forword, so they are engine writes and
engine: falsewas never on the table. What settles theremaining choice is the ledger's own first line,
scripts/tenant-audit-census.mjsatorigin/main1e496f979, line 803: "SHRINK-ONLY, and keyed by (file, receiver) — never byline". Adding two rows to a shrink-only ledger runs against its own discipline. A typed
receiver needs no row at all, so
UNTYPED_RECEIVERSis untouched andplacedByLedgerstaysat 11.
OrgScopingEnginefollowsOrphanCleanupEngineinplugin-sharing: a narrow, locallydeclared interface naming only the doors these functions call —
find,update, and anoptional
registry. Optional on purpose, because "registry unavailable" is a real, tested,logged no-op path that the type has to be able to describe.
It is package-private, and that is load-bearing rather than incidental. The census reads
the type declared at the receiver, in this source tree; it never reads the package's public
entry. Exporting the interface from
index.tstherefore bought the placement nothing and onlywidened a published surface — so
src/index.tsexports exactly the nine names it exportedbefore, byte for byte:
Taken by diffing the
^exportlines of that file asgit showprints them atorigin/mainagainst the same lines at
HEAD: exit 0, no output, 9 lines on each side.Fire control for that zero: the identical comparison run against the commit that did carry
the export reports one added line — the
export type { OrgScopingEngine }re-export — andexits 1. So the comparison can see an added export, and is reporting none.
The emitted declarations still carry
interface OrgScopingEngineinline, so a consumer's callresolves without ever naming it; it is simply absent from the shipped export list.
The caller had to state it too
Naming the parameter turned an invisible coupling into a type error:
OrgScopingQuerySlotinorganizations-plugin.tsdeclared the three members the plugin callsitself — but the plugin also forwards that value to
claimOrphanOrgRows, which writesthrough it. That is the finding, not an obstacle, and the slot now extends
OrgScopingEngineto say so.
Measurements
All commands run in a dedicated worktree on
origin/main@1e496f979afterpnpm install.Acceptance 1 — the generator, on the day (
node scripts/tenant-audit-census.mjs):unledgered— 2 entries, the two sites abovestaleLedgerRows— 0 entries (empty). Fire control for that zero: the same--jsondump reports
unledgered.length = 2andunresolved.length = 2, so the reader is live.Acceptance 3 — both directions, measured twice.
First on the gate-only commit, to show check C is real:
[untyped-receiver])HEAD~1Then again on the finished tree, which is the direction that matters now:
[untyped-receiver]linesql: anycensusRefusals()neuteredThe third row is the control: with check C disabled, the untyped-receiver finding disappears
while the unrelated findings remain — so that red is unambiguously check C's and nothing
else's. All mutations were one-shot, each proven on disk by counting both the injected and
the removed string before any result was read, each script carrying
trap restore EXIT INT TERMwith absolute paths, and each restored to a blob hash equal togit rev-parse HEAD:PATHwithgit diff HEAD --statempty afterwards.The self-test was made to fail before its green was believed: neutering
censusRefusals()reds it with 4 of 24 cases failing by name; deleting the whole
census refusalsbattery blockreds it with
self-test battery "census refusals" DID NOT RUN — 0 cases registered, 5 pinned.The new battery carries its own control (a census with neither an unplaceable site nor a stale
row is not a finding), so its four positive cases cannot be passed by a function that
simply reports everything handed to it.
What the population did. 223 to 225, and both new sites read as elevated. Worth
recording, because on the gate-only commit the population was also 223 with the two sites
unplaced: they were never counted at all — they were the hole in the certified population, and
nothing on the way to a CI verdict said so. The generated region and the audit ledger are
regenerated with
--write; the page's eight hand-written prose figures are restated by hand,which
--writedoes not do.Package verification:
pnpm --filter @objectstack/organizations typecheckandtestbothexit 0 — 8 test files, 108 tests. No test file changed: both fakes are declared
const ql: any,which the narrowed parameter accepts. No in-repo package depends on
@objectstack/organizations,so the consumer sweep is empty by construction rather than by omission.
Clause-②: the whole diff is a narrowing. Two exported function parameters go from
anyto an interface;
OrgScopingQuerySlotis declared withoutexportand stays package-private;and the package entry gains no name, measured above. Nothing relaxes an accepted set and
nothing widens a published surface.
Changeset
A changeset is required and
skip-changesethas been removed — the judgement flipped whenthe diff grew past
scripts/, and it was re-verified rather than assumed.@objectstack/organizationsis not private and shipsfiles: ["dist", "README.md", "CHANGELOG.md"]. Afterpnpm --filter @objectstack/organizations build, the shippeddist/index.d.tsdeclaresclaimOrphanOrgRows(ql: OrgScopingEngine, ...)where it previouslydeclared
ql: any, and exports the newOrgScopingEnginetype. Fire control for that reading:the same grep over the same file scores 0 for a symbol that should not be there.
Bumped
minor, notpatch: runtime behaviour is unchanged, but a consumer passing a valuethat does not structurally offer
findandupdateno longer compiles. Such a consumeralready got
[]and a warning from the existing guards, so nothing that worked stops working— the failure moves from run time to build time.
Gates run
Derived from the diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsand reconciled with
--ran, every line recording its exit code: 105 derived, 105 run, 0NOT-MEASURED, 0 UNRUN, and the tool confirms that zero is derived from the recorded codes
rather than claimed.
pnpm check:pm-dispatch-gates(the bare ~1746-case battery) exits 0in 814.8s, run detached and waited on with
tail --pid.Two families needed a built tree and say so themselves rather than skipping
(
check:skill-examples,check:dual-build-cjs-loadsatexit 3, "PREREQUISITE NOT MET");both were re-run after a full
pnpm buildand both exit 0.One family reds locally and is not this diff:
pnpm check:cross-package-test-inputsreports that
@objectstack/clidescends frompackages/spec/dist/through a radius nodeclared glob reaches, rooted in
packages/cli/test/init-created-files-summary.e2e.test.ts—a file this PR does not touch. It reds only because a local
packages/spec/dist/exists.Proven by moving that directory aside and re-running: exit 0,
29 package(s) read outside themselves, all declared. Thelintjob that runs this gate does not build, so CI sees theunbuilt state. Reported upward as a finding in its own right.
验收备注
卡面四条,原样照抄:
pnpm install,再重跑node scripts/tenant-audit-census.mjs,读今天的退出码与unledgered/staleLedgerRows的实际内容。⛔ 零要有发火对照。lint.yml跑一遍」糊过去(那会让同一份 AST 走两遍),修法落在门禁文件内。Verdicts: 1 — measured above, with a fire control on each zero. 2 —
censusRefusals()in thegate file; no workflow touched; the corpus is walked once. 3 — both directions measured, twice.
4 — the ruling was escalated and returned as "type the receivers, do not grow the ledger"; no
UNTYPED_RECEIVERSrow was written.Two things the card recorded as unmeasured, now measured: the generator does exit 1 on the
day's tree, and
staleLedgerRowsis empty, so the whole of that red was theunplaceable-receiver half.
The two regenerated artefacts
content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdare in the diff as themandatory companions of the population moving 223 to 225, not as independent edits.
node scripts/tenant-audit-census.mjs --write, the gate's one documentedrepair arm. Re-running it on the finished tree rewrites nothing.
--writedoes not touch thepage's hand-written prose:
223to225in five places,74to76, and104to106intwo. Each is a figure
PROSE_COUNTSin the gate holds to the census, and the gate names everyone of them — the eight edits are exactly the eight it named, no more. The hand-written
(47%)beside the elevated share was re-checked and still rounds to 47.git diff --statover both paths is empty after removing the package export, which is theevidence that a visibility change moves no census reading.
Noted, not filed
main()inscripts/tenant-audit-census.mjsreturns 0 from its--writebranch before itreaches the
unledgered/staleLedgerRowsreporting, so--writeis silent about both.It is a repair arm rather than a verdict and CI never calls it — an observation, not a
defect class. Next toucher: anyone regenerating these artefacts, since
--writeis thecommand they run.
Attribution
Authored by Claude Code, session
session_017ef78bLdybu3AffehKkhfk(https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk). The tail footer below this line is
appended by the platform on every body edit, which is why this PR carries its session id in
prose rather than only in that footer.
Generated by Claude Code