Uh oh!
There was an error while loading. Please reload this page.
Gate: an ObjectQL find double in a test must apply the caller's limit, or refuse it loudly - #12030
Conversation
Rescue commit of in-place worktree contents after a container restart. Verification has NOT been re-established at this point; a later commit carries the verified state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
…vidence The control probe granted a seat whenever it had not been disproven, rather than when it had been proven: `allCarry` answers `null` when NO returned row carries the probe's field, and `null !== false`. So a double answering both control probes with rows of its OWN -- constant stubs, and schema-signature fixtures that exist only to satisfy a parse() -- was seated and then graded limit-blind. That is debt with no possible remedy: there is no corpus to bound, and a shrink-only ledger carrying unfixable entries can never burn down. 19 of 294 seated candidates were in that state. They now take the same fallthrough every unseated candidate takes: 15 drop OUT OF SCOPE (the body never filters), 4 are DECLARED unjudged (it does, but the lift cannot drive it). Both directions are pinned by new --self-test fixtures. Baseline re-derived from the corrected measurement: 263 files / 186 blind / 50 unjudged -> 251 files / 167 blind / 54 unjudged. Shape breakers are unchanged at 32, and the header's SHAPED invariant -- which claimed no shape violation was grandfathered while 32 were -- now describes what the ledger holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
commented
Aug 25, 2026
PM handover — this PR is intentionally left as a draft, unarmed. Two things stand between it and landing, and neither is a defect in the work. 1. A ruling is owed on the ledger size — I am not taking it unilaterallyThe dispatch pinned "Shrink-only baseline of 40" as BINDING. The delivered ledger is 251 files / 167 blind / 32 wrong / 54 unjudged. The author named the conflict rather than quietly choosing, which is the right call, and recommends landing as measured. I agree with that recommendation, and record why so the ruling can be checked rather than trusted:
2. The branch is 42 commits stale, and it is a |
…jectql-double-limit-gate
…mit gate REFUSE-UNSPELLABLE: the gate walks packages/ but admits *.test.ts only (2696 of 5161, 52%). packages/** is the only spellable claim and covers 2465 non-test files the gate never opens; every narrower glob collapses to packages//.test.ts and covers 0 of 2696. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
The initial measurement was taken at merge base 311e207. Merging 42 commits of main moved the corpus underneath it: three new test files arrived carrying find doubles, and main's #11962 independently taught the bootstrap-declared-capabilities double to honour the bound. Re-measured: 251 -> 253 files, 167 -> 168 blind, 54 -> 55 unjudged, wrong unchanged at 32. Ratchet DOWN applied, three arrivals seated as pre-existing debt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
…jectql-double-limit-gate
…, not truthiness The double read the caller's bound as `if (q.limit)`, so `limit: 0` -- a request for NOTHING -- returned every matched row. Arrived with #11767 after this branch's ledger was measured; fixed rather than seated, since it is one line in one file. Ledger unchanged: 253 files, 168 blind, 32 wrong, 55 unjudged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-steve
commented
Aug 25, 2026
Status update — correcting my own handover comment above, which is now half stale. That comment listed two blockers. Blocker 2 is resolved. It said the branch was 42 commits behind CI is fully green on Three CI reds were worked through on the way here, and none was a defect in the change:
So the practical shape of the decision: it is not only which number the ratchet is pinned to, it is also how long this PR keeps having to re-measure before it can land. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11525
A
find(object, opts)double inside a test file that matchesopts.whereand hands backevery matched row cannot tell a read bounded at 200 from the same read bounded at 1000, or
from one carrying no bound at all. Every limit change on such a read is green by
construction, and the production symptom is a silently truncated result set rather than an
error. The worked example is #10978's:
resolveUserAuthzGrantsreadssys_membertwice,{user_id}at 200 and{organization_id}at 1000; the obvious "same object, fold the tworeads" cleanup caps
org_user_ids— an RLS input — at 200.PR #11521 made nine such doubles right and measured the population. Nothing held them right.
This is the gate, in the shape #7620 /
check:where-matcherestablished next door and forits recorded reason: "the doubles were made right, and nothing held them right."
The criterion
Refusal counts as conforming on purpose: the defect class is silence, not incompleteness.
A double that throws the moment a bound arrives makes the suite RED, not quietly green.
The three shape rules from the landed nine, and how each is encoded
The gate is behavioural, not syntactic — it lifts each double out of its file and asks it a
bounded question, so a shared helper, a per-file helper and an inline slice all grade
identically.
presenceprobe:limit: 0must return zero rows (0is falsy, soopts.limit ? … : rowsanswers a request for nothing with everything)Rule 3 is provable in one direction only — bounding before or after a pure wrap returns
identical values — and the script says exactly where the encoding stops, pins the two residuals
as self-test fixtures, and prints how many doubles landed in each wrap-order state on every run.
What this branch changes beyond the gate itself
This card was picked up as a rescue after the previous run was lost to a container restart with
its work uncommitted and nothing verified. Verification was re-established from scratch
rather than inherited, and that turned up a defect in the gate's own admission rule.
The control probe granted a seat whenever it had not been disproven, rather than when it had
been proven.
allCarryanswersnullwhen no returned row carries the probe's field, andnull !== false. So a double answering both control probes with rows of its own — constantstubs like
async find(o, q) { return [{ id: 'r1' }]; }, and schema-signature fixtures thatexist only to satisfy a
parse()— was seated on evidence the control never obtained, and thengraded limit-blind. That is debt with no possible remedy: there is no corpus to bound, and a
shrink-only ledger carrying unfixable entries can never burn down.
Measured: 19 of 294 seated candidates were in that state. They now take the same fallthrough
every unseated candidate takes — 15 drop OUT OF SCOPE (the body never filters), 4 are DECLARED
unjudged (it does, but the lift cannot drive it). Two new
--self-testfixtures pin bothdirections.
The header's
SHAPEDinvariant also claimed no shape violation was grandfathered while 32 were;it now describes what the ledger actually holds.
The bare-root verdict this gate owed
scripts/pm/bare-root-worklist.mjs --self-testred on this branch: the gate declaresSCAN_ROOT = 'packages', a bare single-segment literal thatextractWatchHintsbuilds nohint at all from, so the gate is unnameable by any dispatch brief. Every gate newly joining
that species owes a recorded verdict.
The verdict is REFUSE-UNSPELLABLE, established from the gate's own walk rather than by
analogy — though it lands beside its two nearest neighbours,
check:where-matcher SCAN_ROOT packagesandcheck:examples-live-imports PACKAGES_ROOT packages, which are test-files-onlyunder the same root and refused alike.
testFilesUnder()recurses underpackages/and admits/\.test\.ts$/and nothing else, sothe population is a file-KIND filter, not a subtree. Measured on this tree:
packages/(52%)packages/**— the only spellable claimpackages/**/*.test.tspackages//.test.tspackages/*/src/**/*.test.tspackages//src//.test.tspackages/**/src/**packages//srccollapseHintstrips globs, so every glob spelling of the real population reduces to amalformed double-separator prefix that
hintCoversmatches against nothing — a narrowdeclaration would not be a precise hint but a live hint covering zero files. That is the
check:i18nrow's shape, re-measured here rather than assumed.No narrower subtree exists either: the corpus is spread over 28 second-level directories,
and 274 of the 2696 sit outside any
srcsegment, so even thecheck:runner-env-posturesrc-segment shape would be false here as well as uncollapsible.So a declaration is refused in both available directions, and
REFUSE-WIDEwould be the wrongword for it: the population is not the whole root. Declaring
packages/**is the costliererror the self-test names by name — a
finddouble can only ever land in a test file.Merged with
main, and the workflow arithmeticThe branch was 42 commits behind and a
.github/workflows/lint.ymlco-tenant with two commitsthat landed under it —
607c870f4(#12018) and18dea75eb(#11918). Merged with merge commits,both sides of every hunk kept; no rebase, no force-push. A second merge later took the branch up
to
0b048393f, which touched no workflow.The
lintjob's- name:step count reads 186, and nothing was dropped:311e207f8origin/main(then and now)Self-test wiring gateandSelf-test workflow-command gateObjectQL double limit gateA name-by-name diff against
origin/mainshows exactly one line of difference — this PR's ownstep. The earlier 183 → 184 measurement in this PR's history was correct at its merge base;
main moved underneath it.
Both gates
mainadded see this gate's--self-testfor the first time, and both are greenagainst it:
check-self-test-wired(129 scripts CI runs that ship a--self-test, all wired —this one through the
package.jsonalias, which chains--self-testbefore the corpus scan)and
check-self-test-workflow-commands(14 self-tests naming a workflow-command token were runand their real stdout+stderr scanned).
The ledger, and why it is not 40
The card was filed against a census of 49 blind doubles across 43 files, 9 converted, 40 left.
The two numbers are not the same instrument: the earlier one was scoped by hand, this one
seats every candidate by driving it. Seeding the ledger at 40 would have pinned the ratchet to a
number the corpus does not have, and it would have read green forever. The nine doubles PR
#11521 converted are the positive control on the re-derivation — all nine grade CONFORMING, so
all four of their files are absent from the ledger.
The re-measurement, and why those arrivals are not new debt
The initial measurement was taken at merge base
311e207f8. Mergingmainmoved the corpusunderneath the ledger, and the gate reported it in both directions — which is the ratchet
working, not a defect. Every item is
main's doing, none is this PR's:plugin-security/src/packaged-permission-set-restore-leg.test.ts311e207f8; landed by #12021rest/src/meta-compound-save-mode-parity.test.ts311e207f8; landed by #11933service-storage/src/tombstone-hydration-download-agreement.test.ts311e207f8; landed by #11844plugin-security/src/bootstrap-declared-capabilities.test.tsplugin-auth/src/audience-posture.test.tsThe first three are not new debt this PR introduces. They already exist on
mainand wouldhave been in the initial measurement had this branch been cut today. "The baseline never grows"
governs the period after this gate lands; seating them in the initial measurement is the
same act already performed, against the correct tree. Fixing three unrelated test files across
plugin-security,restandservice-storagewould widen this PR across three packages tosatisfy a rule that is not yet in force.
The fourth is the ratchet's other direction firing on its own:
mainfixed a double this branchhad recorded as debt, and the gate refused to let the stale entry stand.
The fifth was fixed rather than seated — one line in one file, and a real defect: the double
read the bound as
if (q.limit), solimit: 0returned every matched row. The ledger isunchanged by it (253 / 168 / 32 / 55); the double now grades CONFORMING.
plugin-auth's suiteis green over the change (77 files, 1586 tests), as is its
typecheck.On the UNJUDGED one specifically.
tombstone-hydration-download-agreement.test.ts:329is adifferent state from BLIND, and it is worth being precise about because the double is
correct:
That is exactly the prescribed shape. It is unseatable because it filters on
file_idandanswers from
joinRowscaptured in its own closure, so the control probe — which deliberatelyuses synthetic field names (
__os_bound_field) precisely so no double can special-case them —reads no evidence that it filtered on the probe's
whereat all. It is theFIXTURE_FOREIGN_ROWSshape, pinned in the self-test in both directions. This is the seatingrule working as designed, not a residual gap: before the
null !== falsefix above, thisdouble would have been graded BLIND — a false accusation against correct code, and
unfixable debt.
unjudgedis the honest column for it: it records that a double is presentwhich the gate could not read, and says nothing about whether it is broken.
Standing exposure: arrivals after measurement
The gate seats candidates behaviourally across the whole
packages/**test corpus, and thisrepo merges to
mainmany times a working day. So any merge that lands a test file carrying anon-conforming
finddouble will red this PR until the ledger is re-measured or the double isfixed. That is the ratchet working on a moving corpus, not an unresolved defect in this branch —
and it resolves itself at landing time, because the merge queue builds against current
main.It is a shrink-only bidirectional ratchet, not a fixed exclusion set, and not a mute button:
a count that grew is an error, a count that fell is an error (ratchet it down in the same PR), a
file with nothing left must lose its entry, and the key set is checked against the merge base so
a newly-added file matching its own count cannot sail through. There is deliberately no
--updateflag. All five directions are demonstrated below.Evidence
Every leg ran under
trap '' EXIT INT TERM, with the mutation confirmed on disk byanchored
grep -con the injected and the removed text — never an editor's exit code, nevera bare
git diff --stat.These five legs were verified at
06074cf0aand have not been re-run: the merges changedthe corpus the gate reads, not the gate's behaviour or what any leg measures. The files changed
since are the ledger, whose contents every leg mutates for itself, and one unrelated test file.
Positive control — the gate fires on doubles that are not in the ledger. A new test file
carrying one blind double, one truthiness double and one conforming double:
The conforming double in the same file is not reported (conforming 76 → 77); candidates
420 → 423 and graded 275 → 278, so all three were seated and each graded on its own merits.
Restore leg: fixture absent, tree clean,
GATE_EXIT=0, counts back to 420 / 275 / 167 / 32.Non-vacuity of the seating fix, reverse-verified. Reverting the one condition back to
=== false— predicted RED in both legs, before running:Exactly the three new assertions fail and no others — the pins bite, and nothing else depended
on the old behaviour.
The ratchet, in every direction.
count fell 3 -> 2· exit 1blind double count grew 1 -> 2· exit 1baselined file is clean or gone· exit 1baseline key set verified against f7e038c: no files added.· exit 0ADDED to the baseline (not present at f7e038c). The grandfather list is not a mute button· exit 1The two MONOTONIC legs needed a base that actually carries the ledger, so the ref list was
pointed at this branch's own first commit for the measurement and restored. On the branch as it
stands the gate prints
NOT verified: could not read the baseline at the merge base with main—honest and expected, because the ledger is new on this branch and there is nothing at the
merge base to compare against. The
lintjob checks out withfetch-depth: 0, so the checkreads it on every run once this lands.
The open design question the card asked, answered by measurement
The card asked whether one shared
bounded()helper is right or per-file copies are, andrequired a measurement rather than inheriting #7620's no-shared-substrate ruling.
The card's premise holds: the bound really does have a single lowest common denominator. Of the
files that read it by presence, 74 of 79 spell it
typeof ?.limit === 'number'.What varies is everything around it. The options bag is named six ways —
query21,options18,
ast18,opts14,q6,o1 — and the row source takes at least four shapes (a closurearray,
tables[object],storeFor(o).values(),store.get(o)), which is why the gate's liftneeds a row stub that answers as an array, a map and a callable at once.
Measured recommendation: per-file copies — for a different reason than #7620's. Not because
capability varies (it does not; the card was right), but because the shared part is already a
single expression, so a per-file copy costs one line, while sharing it would add a
cross-package test-input dependency across ~250 files in 24 package directories to save that one
line each — the very thing
check:cross-package-test-inputsexists to police.The gate takes no side either way:
FIXTURE_SHARED_HELPERpins that a shared helper and aper-file copy grade identically, so this question stays open for the conversion lane in #10978
without blocking the gate.
Verification
Gate families derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackwith no hand-written path list (it reads its own change set — 6 paths vs merge base
0b048393f). 31 families, all green, at7a10cfa5e— the final commit of this branch, treeclean. Every exit code was captured before any pipe.
The 25 families the tooling paths pull in were run at
2bd0e5cdb; adding the one test filepulls in 6 more, run at
7a10cfa5ealong with both ratchet families and the edited package:The 25 at
2bd0e5cdb:check:type-check-debtfirst refused to run — it re-measures withtscresolving workspaceimports through each dependency's built
dist/*.d.ts, and this rescue worktree had never beenbuilt. That refusal is the gate declining to measure a different world, not a failure. The
closure was built exactly as
lint.ymldoes before that step(
turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) under theshared verify lock, and the gate then measured green.
No changeset
CI tooling plus one test-file fix: a gate script, its ledger, one workflow step, one script
alias in the private root
package.json, and a test double inplugin-auth. Nothingshipped from
packages/**changes and nothing is published, so this carries theskip-changesetlabel rather than a changeset.Refs: #10978 (parent — remains open for the remaining 168) · PR #11521 (the nine converted,
with the census) · #7620 /
check:where-matcher(the precedent) · #11190 / PR #11512 (theordering constraint, released —
c96cbfc48carries both numbers).Generated by Claude Code