Skip to content

fix(lint): give the tenant-audit census refusals an exit to CI - #18534

Merged
os-try-charles merged 5 commits into
mainfrom
claude/issue-18211-tenant-audit-gate-exit
Sep 17, 2026
Merged

os-try-charles merged 5 commits into
mainfrom
claude/issue-18211-tenant-audit-gate-exit

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18211

Clause-②: no

What

Two halves, and the second is what makes the first landable.

  1. scripts/check-tenant-audit-census.mjs gains check C, censusRefusals() — it reads
    census.unledgered and census.staleLedgerRows off the census it already runs and routes
    both to the gate's exit code.
  2. The two sites that check C then found are fixed at the receiverclaimOrphanOrgRows
    and claimOrgSeedOwnership took ql: any, so they are given the narrow OrgScopingEngine
    type 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 placement
rules reaches) and staleLedgerRows (an UNTYPED_RECEIVERS row matching no call).

The generator's own main() prints both and exits 1. But lint.yml (lines 1995/1996 on this
tree) invokes the gate, never the generator — and the gate read neither field. Measured on
this branch's base 1e496f979:

file unledgered reads staleLedgerRows reads control: writeCallSites reads
scripts/check-tenant-audit-census.mjs 0 0 17
scripts/tenant-audit-census.mjs 3 3 5

The 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 Gates
stayed green — a defect arriving as compliance. This direction is published:
content/docs/permissions/tenant-audit-census.mdx tells readers that a receiver none of the
three 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: any seed/back-fill helpers writing
schema.name under context: SYSTEM_CTX:

  • packages/plugins/organizations/src/claim-org-seed-ownership.ts (:52 the parameter, :93 the write)
  • packages/plugins/organizations/src/claim-orphan-org-rows.ts (:61 and :106)

Both match the already-ledgered plugin-security/src/claim-seed-ownership.ts row word for
word, so they are engine writes and engine: false was never on the table. What settles the
remaining choice is the ledger's own first line, scripts/tenant-audit-census.mjs at
origin/main 1e496f979, line 803: "SHRINK-ONLY, and keyed by (file, receiver) — never by
line"
. Adding two rows to a shrink-only ledger runs against its own discipline. A typed
receiver needs no row at all, so UNTYPED_RECEIVERS is untouched and placedByLedger stays
at 11.

OrgScopingEngine follows OrphanCleanupEngine in plugin-sharing: a narrow, locally
declared interface naming only the doors these functions call — find, update, and an
optional 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.ts therefore bought the placement nothing and only
widened a published surface — so src/index.ts exports exactly the nine names it exported
before, byte for byte:

Taken by diffing the ^export lines of that file as git show prints them at origin/main
against 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 — and
exits 1. So the comparison can see an added export, and is reporting none.

The emitted declarations still carry interface OrgScopingEngine inline, so a consumer's call
resolves 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:
OrgScopingQuerySlot in organizations-plugin.ts declared the three members the plugin calls
itself — but the plugin also forwards that value to claimOrphanOrgRows, which writes
through it. That is the finding, not an obstacle, and the slot now extends OrgScopingEngine
to say so.

Measurements

All commands run in a dedicated worktree on origin/main@1e496f979 after pnpm install.

Acceptance 1 — the generator, on the day (node scripts/tenant-audit-census.mjs):

  • exit code 1
  • unledgered2 entries, the two sites above
  • staleLedgerRows0 entries (empty). Fire control for that zero: the same --json
    dump reports unledgered.length = 2 and unresolved.length = 2, so the reader is live.
  • Population: 223 write call sites, 569 sources scanned.

Acceptance 3 — both directions, measured twice.

First on the gate-only commit, to show check C is real:

direction how gate exit
red on the day's sites check C, before the receivers were typed 1 (2 x [untyped-receiver])
same tree, no check C HEAD~1 0
green once the sites are placed one-shot ablation removing them from the corpus 0

Then again on the finished tree, which is the direction that matters now:

leg gate exit [untyped-receiver] lines
finished tree 0 0
erase ONE receiver back to ql: any 1 1, naming the file and line
same mutated tree, censusRefusals() neutered 1 (drift/prose only) 0

The 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 TERM with absolute paths, and each restored to a blob hash equal to
git rev-parse HEAD:PATH with git diff HEAD --stat empty 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 refusals battery block
reds 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 --write does not do.

Package verification: pnpm --filter @objectstack/organizations typecheck and test both
exit 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 any
to an interface; OrgScopingQuerySlot is declared without export and 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-changeset has been removed — the judgement flipped when
the diff grew past scripts/, and it was re-verified rather than assumed.
@objectstack/organizations is not private and ships files: ["dist", "README.md", "CHANGELOG.md"]. After pnpm --filter @objectstack/organizations build, the shipped
dist/index.d.ts declares claimOrphanOrgRows(ql: OrgScopingEngine, ...) where it previously
declared ql: any, and exports the new OrgScopingEngine type. Fire control for that reading:
the same grep over the same file scores 0 for a symbol that should not be there.

Bumped minor, not patch: runtime behaviour is unchanged, but a consumer passing a value
that does not structurally offer find and update no longer compiles. Such a consumer
already 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 --commands
and reconciled with --ran, every line recording its exit code: 105 derived, 105 run, 0
NOT-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 0
in 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-loads at exit 3, "PREREQUISITE NOT MET");
both were re-run after a full pnpm build and both exit 0.

One family reds locally and is not this diff: pnpm check:cross-package-test-inputs
reports that @objectstack/cli descends from packages/spec/dist/ through a radius no
declared 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. The lint job that runs this gate does not build, so CI sees the
unbuilt state. Reported upward as a finding in its own right.

验收备注

卡面四条,原样照抄:

  1. pnpm install,再重跑 node scripts/tenant-audit-census.mjs,读今天的退出码与 unledgered / staleLedgerRows实际内容。⛔ 零要有发火对照。
  2. 给这两个字段一条到 CI 的出口。⛔ 不许用「把 census 也加进 lint.yml 跑一遍」糊过去(那会让同一份 AST 走两遍),修法落在门禁文件内。
  3. 两个方向的对照都要:门禁对今天这些站点必须红;去掉其中一个(或补上 ledger 条目)之后必须绿。⛔ 只给一个方向不算量过。
  4. 不许为了让门禁绿而把那些站点写进 ledger 当既成事实 —— 先裁它们该不该被落位,再决定记不记。⚠️ 若你判断需要这次裁定,回报 PM 席,⛔ 不要自己裁。

Verdicts: 1 — measured above, with a fire control on each zero. 2 — censusRefusals() in the
gate 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_RECEIVERS row was written.

Two things the card recorded as unmeasured, now measured: the generator does exit 1 on the
day's tree, and staleLedgerRows is empty, so the whole of that red was the
unplaceable-receiver half.

The two regenerated artefacts

content/docs/permissions/tenant-audit-census.mdx and
docs/audits/2026-08-tenant-audit-write-call-sites.counts.md are in the diff as the
mandatory companions of the population moving 223 to 225, not as independent edits.

  • Both are written by node scripts/tenant-audit-census.mjs --write, the gate's one documented
    repair arm. Re-running it on the finished tree rewrites nothing.
  • Eight figures on the page were changed by hand, because --write does not touch the
    page's hand-written prose: 223 to 225 in five places, 74 to 76, and 104 to 106 in
    two. Each is a figure PROSE_COUNTS in the gate holds to the census, and the gate names every
    one 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.
  • Nothing else on either artefact was hand-edited, and the last round of work moved neither:
    git diff --stat over both paths is empty after removing the package export, which is the
    evidence that a visibility change moves no census reading.

Noted, not filed

  • main() in scripts/tenant-audit-census.mjs returns 0 from its --write branch before it
    reaches the unledgered / staleLedgerRows reporting, so --write is 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 --write is the
    command 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

`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>
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 16, 2026 — with Claude
…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>
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 582d3e54ea3d0db31c56b5c624cd3195ef5043eepackageMentionDocs.

Which tree this was computed on

This run read content/docs from e5e8cb016fd10965705136c991b5557772cd278b — the merge of head 006f8f3ab7fb5ae3e0ae9ca8632628c43281a751 into base 582d3e54ea3d0db31c56b5c624cd3195ef5043ee, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 16, 2026
… 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>
@os-try-charles os-try-charles removed the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 16, 2026
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>
@os-try-charles
os-try-charles marked this pull request as ready for review September 17, 2026 00:12
@os-try-charles
os-try-charles added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 79a046f Sep 17, 2026
44 checks passed
@os-try-charles
os-try-charles deleted the claude/issue-18211-tenant-audit-gate-exit branch September 17, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] tenant-audit 普查器的 ERROR 方向送不到 CI —— 门禁一次都没读 unledgered / staleLedgerRows

2 participants