Skip to content

test(client): pin the environments.* any-CONTAINING family by membership - #19422

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-19383-environments-any-containing-guard
Sep 20, 2026
Merged

os-project-manager merged 2 commits into
mainfrom
claude/issue-19383-environments-any-containing-guard

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #19383

Clause-②: no

A prose docblock was the only thing holding the 14 any-CONTAINING methods on ObjectStackClient.environments.*. This PR enumerates the family by name, in a pin that fails when a 15th arrives.

The census — instrument first, because a grep cannot answer this

ts.createProgram + TypeChecker, asking the two halves separately, over the same walk scripts/check-exported-any-returns.mts uses so the keys line up with its ledger:

  • (a) does the declaration node write an explicit return type — a source AST property. It is invisible in a built .d.ts because tsup always emits one, and this run measures exactly that: 292/331 annotated in source, 318/318 in dist.
  • (b) does checker.getAwaitedType CONTAIN any — a type property. Invisible to text, because these methods carry no annotation at all, so any / Promise / unwrapResponse never appear on a signature line.

Taken in objectstack-ai/objectstack at 8ddefbc977da (the branch point), both programs built from that tree.

Control, before any reading was believed: 17/17 fixture cases land in their pre-declared bucket — including Record<string, any>, the mapped type whose typeArguments is empty and which put the #11925 instrument's first version in the clean bucket. The descent reaches it through getIndexInfosOfType.

What it found

population count
callables reached on the exported surface (source) 331
carrying no return annotation 39
of those: awaited type IS any 2 — both already in the ratchet's ledger
of those: awaited type CONTAINS any 16
ObjectStackClient.environments.* callables 21, none annotated
of those: CONTAINS any 14

The card's count of 14 reproduces exactly, and so does its membership: list, get, create, update, activate, rotateCredential, updateHostname, retryProvisioning, and packages.list, packages.install, packages.get, packages.enable, packages.disable, packages.upgrade.

The clean bucket was read, not assumed. The 7 environments.* callables that are NOT in the family — delete, listRevisions, listBranches, renameBranch, deleteBranch, listDrivers, packages.uninstall — are unannotated too and concrete anyway ({ environmentId, deleted, archived, purgeDeferred, retention… }, { drivers: Array of name+driverId, total }, { id, success }). They are what proves this is a real discriminator and not "the whole namespace".

The gate's own run on the same tree reports 318 callables reached (52 caller-supplied generics), 2 ledgered site(s) still open — the dist half of this census reached 318 with 52 generics, so the instrument and the landed ratchet agree on the population they share.

The decision: a guard, but NOT the one the card sketched

The card suggested an allow-list over a CONTAINS-any detector. The census says that instrument is the wrong one, for three measured reasons.

1. CONTAINS-any is not a predicate, it is a predicate plus a hop budget. Package-wide population by how far the walk may descend: 24 at 3 hops, 43 at 4, 57 at 5, 57 at 6. An unbounded walk does not terminate in practice — it forces instantiation of the whole @objectstack/spec type graph and OOMs at 8 GB, measured twice. And 144 callables are still depth-truncated at 6 hops, so the clean bucket is never proved clean. A gate built on this would have to declare an arbitrary constant, and its green would mean "no any within N hops".

2. Its false positives are exactly the class the ledger protects by name. Of the 16 unannotated any-containing sites, the 2 outside this namespace are organizations.list (better-auth organisation metadata) and oauth.applications.list (Record<string, any>[], the opaque OAuth client row) — the ledger's own two examples. Widen to the annotated surface and it gets worse: at 4 hops, 29 of 43 hits are Response.json() (lib.dom), AsyncIterable's TReturn, or the FilterCondition operator bag. False-positive rate 12.5% on the unannotated slice, 67.4% at 4 hops, 75.4% at 6.

3. The two vantage points disagree, and the gate's vantage point is the wrong one here. check:exported-any-returns reads the built .d.ts, and must, for half (b) of the unannotated methods. But tsup emits private query; with no type, so it resolves to any — and 13 callables are CONTAINS-any in dist and clean in source on that account alone (QueryBuilder's 11 chainable methods, createQuery, and ObjectStackClient.environment). Half (a) is unreadable there anyway.

What landed instead

A membership pin — packages/client/src/environments-any-family.pin.test.ts — which asks the bounded question the census can actually settle, and never asks CONTAINS-any at build time.

  • What it refuses: nothing. It refuses no code. It turns a 15th method from a silent addition into a diff whose author adds a name.
  • Cost per new method: one union member (and one array entry in the runtime half) if its envelope carries any; zero if it is bound to a concrete contract. Binding one of the 14 is the shrink-only direction: remove the name.
  • False-positive rate against caller-shaped any: 0/21 measured. The namespace has no caller-supplied generic at all, which is precisely why the scope stops at the namespace.

Four assertions, all compiled by tsconfig.test.json (which package.json's typecheck script names, so none is the phantom class AGENTS.md warns about): the exact key set of environments (15) and of environments.packages (7), and the exact any-carrying subset of each (8 + 6 = 14). A runtime Object.keys half catches a 15th method even when it is fully typed, because #12036's blanket licence would cover that one too.

The index.ts docblock now points at it and says the licence is bounded — the half that makes the two findable from each other.

Ablation — every leg mutated on disk, measured, and restored byte-identically

All three through scripts/ablation-replace.mjs, from the committed state, each restore verified as blob == HEAD with git diff HEAD empty. No dist rebuild was needed: the pin imports ./index relatively, so both tsc -p tsconfig.test.json and vitest read source.

leg mutation reading
a 15th method arrives insert ablationFifteenth returning { environment: any } into environments tsc red at EnvironmentsKeysArePinned and EnvironmentsAnyFamilyIsPinned (TS2344, Type 'false' does not satisfy the constraint 'true')
same mutation, runtime half as above vitest red: expected [ 'ablationFifteenth', …(15) ] to deeply equal [ 'activate', 'create', …(13) ]
the predicate is a constant IsAny collapsed to always-true all five FALSE-side controls red (lines 143–146, 148)
the predicate is a constant IsAny collapsed to always-false all five TRUE-side controls red (lines 136–140)

The two collapse legs are why the control battery is not decoration: a predicate stuck on either verdict dies in the file, and the expected direction was declared before each run.

Tests

At 98784aa (this branch's head):

  • pnpm --filter @objectstack/client test49 files, 568 tests, all pass.
  • pnpm --filter @objectstack/client typecheck — pass, including check:test-typecheck (0 file(s) / 0 error(s) held in test-typecheck-debt.json).
  • pnpm --filter @objectstack/client check:exported-any-returns — self-test pass, then no NEW exported callable resolves to any: 318 callables reached (52 caller-supplied generics), 2 ledgered site(s) still open. Unchanged, as intended: this PR adds nothing to that ledger and removes nothing from it.
  • pnpm --filter '@objectstack/client...' build — pass.
  • The gate families derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack were run; results in the report on the card.

Changeset — patch, and the class, measured

.changeset/19383-environments-any-family-membership-pin.md, @objectstack/client: patch.

skip-changeset was tested and refused by measurement, not by judgement. The package's files[] is ["dist","README.md","CHANGELOG.md"]; after a rebuild, the pin file's symbols get 0 hits anywhere under dist/, but the docblock paragraph gets 4index.js, index.mjs, index.d.ts, index.d.mts, because tsup keeps the JSDoc in the JS output too. So the diff publishes.

The class is documentation-only change to published bytes: no export added, removed or renamed; no signature, envelope key, accept set or runtime behaviour moves; nothing an author can write changes. That is patch — not minor (nothing new is authorable) and not breaking (nothing narrows). Clause-②: no, so no ADR-0087 marker applies.

Acceptance notes

Noted here rather than filed — neither fits a filing class (no reproducible defect, no violated declared contract, no metadata-authoring trap):


Generated by Claude Code

A prose docblock was the only thing holding the 14 `any`-carrying methods on
`ObjectStackClient.environments.*`. `check:exported-any-returns` asks whether
an awaited return type IS `any` and never whether it CONTAINS one -- a
deliberate, documented scope that buys the gate zero false positives -- and
the sites carry no return annotation at all, so no text search can see them
either. A 15th such method landed silently green.

Pinned by membership rather than by a CONTAINS-any detector, because a census
over the built surface shows CONTAINS-any has no canonical boundary here: the
population is a function of the walk's hop budget (24 at 3 hops, 43 at 4, 57
at 5 and 6), an unbounded walk does not terminate, and a package-wide rule
flags the caller-shaped `any` the ratchet's ledger protects by name.

The membership reading is stable across every bound measured: 21 callables on
the namespace, 0 annotated, 14 carrying `any` in their own envelope.

Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-authored-by: Claude <noreply@anthropic.com>
The diff's only published byte change is one TSDoc paragraph on
`ObjectStackClient.environments`, measured in all four emitted artifacts
(index.js, index.mjs, index.d.ts, index.d.mts). The pin file itself ships
nothing -- zero hits for its symbols under the package's `files[]`.

Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 1 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/api/environment-routing.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/api/wire-format.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/kernel/runtime-services/data-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/kernel/runtime-services/storage-service.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via ObjectStackClient (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ObjectStackClient (symbol, a top-level class))
  • content/docs/releases/v17/17-2.mdx (via ObjectStackClient (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 15 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 f20fe298a18e23d99791cfe34b52aaf229d039d3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 54112f827e4711b7e70901bc0669613f33c9138f — the merge of head 98784aa33f48b99fce82fcec978e1b04e738b87b into base f20fe298a18e23d99791cfe34b52aaf229d039d3, 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 54112f827e4711b7e70901bc0669613f33c9138f && git checkout 54112f827e4711b7e70901bc0669613f33c9138f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f20fe298a18e23d99791cfe34b52aaf229d039d3 98784aa33f48b99fce82fcec978e1b04e738b87b && git checkout -B drift-repro f20fe298a18e23d99791cfe34b52aaf229d039d3 && git merge --no-ff 98784aa33f48b99fce82fcec978e1b04e738b87b

node scripts/docs-audit/affected-docs.mjs --json f20fe298a18e23d99791cfe34b52aaf229d039d3

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f20fe298a18e23d99791cfe34b52aaf229d039d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

ACCEPT — and the order's sketch was the wrong instrument, measured

Head judged: 98784aa33f48b99fce82fcec978e1b04e738b87b. Read at 2026-09-20T18:59Z.

The census, re-measured independently at origin/main d9282a4bd7

I did not take the report's numbers. I re-derived the population from
packages/client/src/index.ts on current main — not on the PR's base
f20fe298a1 — pairing every callable in the environments block with the full
type argument of its own unwrapResponse<…>:

21 callables   (14 on environments.*, 7 on environments.packages.*)
 0 annotated   (every one is `name: async (…) => {`, no return type)
14 any-carrying / 7 clean

Name for name against the file's four unions:

  • environments any-carrying — list, get, create, update, activate,
    rotateCredential, updateHostname, retryProvisioning (8). Match.
  • environments clean — delete, listRevisions, listBranches,
    renameBranch, deleteBranch, listDrivers (6). Match.
  • environments.packages any-carrying — list, install, get, enable,
    disable, upgrade (6). Match.
  • environments.packages clean — uninstall ({ id; success }). Match.

The control is the 7, and it can fail. A predicate that answered true
for everything would put listDrivers ({ drivers: Array<{ name; driverId }>; total }) and uninstall on the any side; a predicate stuck on false would
empty the other. Both sides are non-empty and both are the sides the file
pins, so the reading discriminates rather than being satisfied by its own
shape. packages/client/ is byte-identical between the PR's base and current
main, so this is a reading of the tree the PR will land onto, not of its
parent.

The 2-hop bound is sufficient here, not merely convenient. Every one of the
14 writes its any directly into its own envelope literal — as a member
({ environment: any }) or as a member array's element
({ environments: any[] }). None of them needs a third hop. That is the
property that makes the population stable across the 3/4/5/6 budgets the report
measured, and it is why ControlBeyondTheBound can declare the bound openly
instead of hiding it.

Not a phantom check. packages/client/tsconfig.test.json includes
src/**/*, and package.json's typecheck names it
(tsc --noEmit && pnpm check:test-typecheck, the latter -p tsconfig.test.json).
The type assertions are compiled by a script CI runs. Ablation leg 1 — a 15th
method turning tsc red at both pins with TS2344 — is the direct proof, and
legs 3 and 4 show all ten controls fire in their declared directions.

On the disagreement

The dispatch sketched an allow-list over a CONTAINS-any detector. That sketch
was wrong and this PR is right to have refused it.
The report's three grounds
are the reason, and each is a measurement rather than a preference:

  1. CONTAINS-any over this surface is a predicate plus a hop budget — 24/43/57/57
    at 3/4/5/6 hops, 144 callables still truncated at 6, and unbounded it OOMs at
    8 GB twice. A gate like that can never mean "no any", only "no any within
    N", and its clean bucket is never proved clean.
  2. Its false positives are the ledger-protected class by name
    organizations.list and oauth.applications.list are the two examples
    exported-any-returns.json protects, and they are what a package-wide rule
    hits first.
  3. Source and dist disagree on 13 callables because tsup erases private field
    types, so such a gate's verdict would depend on which artifact it read.

Triage's warning about "a gate born with a 14-entry exemption list" is answered,
not dodged: there is no exemption list here. The 14 names are the family,
read from the tree, and the 7 absentees are what proves the pin is not just
"the whole namespace".

The order asked for an explicit statement if the design was judged settled
enough to land. It was given, with its reason, in both the PR body and the
report. That is the shape the order asked for.

Recorded, not requested

The file's docblock says the cost of a 15th method is "add its name to the union
below, in a diff someone reads" — singular. The actual cost is up to four edit
sites (EnvironmentsKeysArePinned, the runtime toEqual array, and for an
any-carrying method EnvironmentsAnyFamilyIsPinned plus the pinned map and
its toBe(14)). The friction is the deliverable and every site is a line
someone writes on purpose, so this is an imprecision in the prose rather than a
defect in the pin. It is recorded here instead of spent on a round trip: the
diff is test-only, and a fresh CI cycle on a p3 to sharpen one clause is not
proportionate.

Gates

  • Clause-②: no, declared on the governing claim and on the PR body;
    check-clause2-carriers.mjs --pair 19422 exits 0 — the label state matches on
    both carriers and the diff carries no widening tell. No at-tier contract
    review of record is owed.
  • Changeset patch, and the class was measured rather than judged:
    skip-changeset is refused because the docblock paragraph reaches dist/
    (4 hits) while the pin file's symbols reach it 0 times. Nothing an author can
    write moves, so not minor; nothing narrows, so no **BREAKING** banner and
    no ADR-0087 marker.
  • packages/client/src/index.ts carries 0 non-comment lines in this diff.
    No docs sweep: the anchor is the ObjectStackClient class itself — the
    coarsest anchor the drift check has, matching every page that names the SDK —
    and a diff with no non-comment line cannot have falsified a published
    assertion.
  • CI on the judged head: 30 success, 3 skipped, Lint & Repo Gates still
    running; legacy combined status success (Vercel).

Readying and arming auto-merge. The merge queue takes it from here.


Generated by Claude Code

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 tests tooling

Projects

None yet

2 participants