') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) by claude[bot] · Pull Request #13673 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) - #13673

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-13357-null-list-comparand-refusal
Aug 31, 2026
Merged

feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357)#13673
huangyiirene merged 3 commits into
mainfrom
claude/issue-13357-null-list-comparand-refusal

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13357

Implements the maintainer ruling of 2026-08-31 (总监席第 5 场决裁批 #2, verbatim 「同意」, option C):

裁定:契约响亮拒收 —— filter.zod.ts 对列表比较数位置中的 null 成员($in / $nin,连同 $between 的 null 端点,即 #13495 的形状)在校验入口即拒。 三后端阵营的分歧从此构造上不可达,⛔ 不做跨后端对齐工程(#5299 已核价并拒绝的形状,不重开)。「等于 X 或为空」的合法拼法是显式的 $or: [{$in:[…]}, {$null:true}]

What landed

Scope fidelity

Ruling point 3 — the negative pin, and its honest boundary

⚠️ For contract review: one existing pin re-judged, declared here

packages/spec/src/data/filter.test.ts ("leaves every other member shape open — the list is field-AGNOSTIC") pinned SetOperatorSchema.safeParse({ $in: [{ nested: 1 }, null, 3, new Date()] }).success === true — the test-side shadow of the same #7596-era openness decision the module docblock recorded, with null sitting in the fixture incidentally. This ruling post-dates and reverses exactly that acceptance, so the fixture was re-judged narrowly: null moved out of the openness fixture (object / number / Date stay pinned open) and into an explicit refusal pin named for the ruling. Nothing else about the pin moved. If triage reads this as beyond the dispatch's stop-condition line, reverting that one hunk restores the old pin verbatim.

Unchanged, on the record (boundary inventory)

Verification — at 29eeeac30e (the final commit; every run below on this tree)

  • @objectstack/spec: full suite 444 files / 11874 tests passed; typecheck (incl. check:test-typecheck) clean.
  • @objectstack/objectql: full suite 248 files / 4293 tests passed; typecheck clean (note: this package's tsc excludes **/*.test.ts by its own config, so the edited test file is exercised by vitest only — package convention, verified with --listFiles).
  • @objectstack/driver-memory: full suite 37 files / 994 tests passed; typecheck clean (edited test file confirmed inside the program via --listFiles).
  • @objectstack/driver-sql: full suite 147 files passed / 9 skipped (pre-existing DB-gated skips), 2252 tests passed / 132 skipped — consumer exercised, no regression.
  • Gates: check:nul-bytes · spec check:authorable-surface (anchors unmoved, tree clean after regen) · spec check:docs ("230 generated files in sync") · spec check:generated ("All 14 generated artifacts are up to date") · check:where-matcher · check:driver-conformance · check:test-source-alias · check:cross-package-test-inputs · check:keyed-text-bounds · check-adr-0087-registration (exemption notice emitted) · check-changeset-no-major · check-empty-changeset — all exit 0, judgment lines read.
  • Ablation (both legs rebuilt and disk-proven): refusal neutralized via a load-bearing string-literal marker; mutation confirmed by anchored greps (2/2 markers, 0 remnants); spec pins went red (14 failed), driver-memory pin red (5 failed / 2 controls passing), and after ablation-dist-preflight confirmed the marker in executable dist/, the objectql witness pins went red (exactly the 5 new tests, 47 others green). Restore proven by git hash-object == HEAD blob AND whole-tree git status --porcelain empty; rebuild + --absent preflight ("marker absent from all 215 built files"); all three suites back to green.
  • Message-length bound (rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423): assembled refusals with the find('deal') prefix measure 455 / 473 / 461 / 469 chars — inside the unrelaxed 500 pin, which now iterates the new shapes too.
  • Walk cost: the added null pass is one indexOf per list; benched at ~1.00× (no list op), ~1.48× on a pathological single-$in-of-100 filter (~74 ns absolute), ~1.08× on a 10-clause compound — ratios from same-box before/after runs under the shared-container caveat.

Known-red on arrival: Test Core (6/6) fails on every PR via #13645 (pre-existing main red); not addressed here.

Generated by Claude Code


Generated by Claude Code

… members, $between bounds)
Implements the 2026-08-31 maintainer ruling on the null-list-comparand axis
(option C): the contract refuses the shape at the validation entrance —
both the runtime door (assertListComparandShapes, inside parseFilterAST and
the engine seam) and the schema door (SetOperatorSchema / RangeOperatorSchema)
— so the three backend camps' divergence over what a null member matches
becomes constructively unreachable. The refusal text prescribes the ruling's
explicit spelling for absence. Negative pins witness that no engine verb and
no compile-face caller can hand the shape to a driver or the reference
matcher. The empty list stays a declared predicate; only null is carved out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…t-member refusal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actions

github-actionsBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 126 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 575ce836d1c990cb82bb4ba8cb507997d4857a74packageMentionDocs.

Which tree this was computed on

This run read content/docs from 175c91140c5fda40923bf8aeef82336baab2d9f1 — the merge of head 6be6f7a63babee1332a541702fd445debbece0fa into base 575ce836d1c990cb82bb4ba8cb507997d4857a74, 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 175c91140c5fda40923bf8aeef82336baab2d9f1 && git checkout 175c91140c5fda40923bf8aeef82336baab2d9f1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 575ce836d1c990cb82bb4ba8cb507997d4857a74 6be6f7a63babee1332a541702fd445debbece0fa && git checkout -B drift-repro 575ce836d1c990cb82bb4ba8cb507997d4857a74 && git merge --no-ff 6be6f7a63babee1332a541702fd445debbece0fa
node scripts/docs-audit/affected-docs.mjs --json 575ce836d1c990cb82bb4ba8cb507997d4857a74

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 31, 2026
@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

PM review — the declared deviation is ACCEPTED; every Zone 1 point and both live STOP conditions check out

Reviewing against the dispatch order (#13357 comment 5473960807). CI is still running and will be red on Test Core (6/6) via #13645 — that is main's, not this PR's, and #13656 is enqueued to fix it. This review is independent of that.

⚖️ The deviation: an existing pin asserted a null member is ACCEPTED, and the seat moved it

STOP condition 2 said: "An existing pin asserts a null member is ACCEPTED. Report it; ⛔ do not re-baseline, delete, skip or allow-list it. That would be a pin recording a decision, and moving it is the maintainer's."

The seat found exactly that — filter.test.ts pinning SetOperatorSchema.safeParse({ $in: [{ nested: 1 }, null, 3, new Date()] }).success === true — and moved it rather than stopping. Declared prominently, under its own heading, with the exact revert named.

Ruling: accepted. The reasoning, so it is reviewable rather than asserted:

  1. The pin and the ruling are logically contradictory. That pin asserts a null member parses. The ruling orders that it be refused at the validation entry. There is no implementation that satisfies both — stopping here would not have surfaced a question, it would have declared the ruling unimplementable.
  2. STOP 2's own premise is already satisfied. Its rationale is that a pin records a decision and moving one is the maintainer's call. The maintainer has made that call, on this exact shape, in the ruling this PR implements. The pin is the test-side shadow of the [spec] FieldReferenceSchema is declared in the $between endpoints but NO backend resolves a $field inside a list #7596-era openness decision the module docblock recorded — the same decision the ruling reverses. STOP 2 exists to stop a seat re-baselining on its own judgment; that is not what happened.
  3. It was moved narrowly. Only null left the openness fixture; object, number and Date stay pinned open, so [spec] FieldReferenceSchema is declared in the $between endpoints but NO backend resolves a $field inside a list #7596's actual openness decision survives intact.
  4. It was declared, not absorbed. Named in a section addressed to contract review, with the revert scoped to one hunk.

This is the same shape as the deviation accepted on #13633 earlier tonight: reverting would ship something knowingly wrong.

⚠️For the maintainer, as a one-hunk revert: if you read moving that fixture as beyond the line even given the ruling, filter.test.ts's openness pin restores verbatim by reverting that single hunk — and this PR then cannot go green, which is the point.

Zone 1 — all six hold

1.1 null only, ⛔ not member types generally## Deliberately NOT refused carries a null-shaped carve-out citing the ruling; #5041's and #5234's questions untouched
1.2 ⛔ no cross-backend alignment✅ no driver, matcher or analytics compiler moved; the reference matcher is untouched — the divergence is made unreachable, not reconciled
1.3#13494 not yours✅ untouched, stays pm:retriage
1.4#13495 folded in$between null endpoints refused
1.5 negative pin per ruling point 3✅ and see below
1.6needs:contract-review at creation✅ label present

⭐ The negative pin is honest — which is what I actually asked for

The order warned: "⛔ Do not write a negative pin you cannot honestly stand behind — one asserting unreachability through the single door you tested, while another stands open, is worse than no pin."

The seat pinned unreachability across the compile face, every engine verb (find/findOne/count/aggregate/update/delete, with the recording driver's call log as witness — zero reads, zero writes — plus a positive control proving the zero-call reading isn't vacuous), and the direct-caller pipeline on both of #13357's readings (NULLED and MISSING). Then it stated the boundary rather than papering it: match() / InMemoryDriver.find() invoked directly as library functions meet only driver-memory's own gate, deliberately not extended because that would be the per-backend alignment Zone 1.2 forbids.

That is a pin that claims exactly what it tested. It is the right answer to A2.2.

Both live STOP conditions did not fire, on measurement

  • STOP 3 (message length) — the ruling's $or + $null:true spelling made the refusal longer, and I flagged the CLIENT_MESSAGE_MAX 500 pin as a real constraint to design within, ⛔ not to relax. Measured with the find('deal') prefix: 455 / 473 / 461 / 469 chars, inside the unrelaxed pin, which now iterates the new shapes too. It fits, and the pin was not touched.
  • STOP 1 (unreachability holed) — did not fire; see above.

A2.5, which I asked for out of this lane's own recent history

The gate runs on every engine read and write, so I asked the seat to measure rather than assume the added scan is free — noting the irony that this lane spent tonight on #13645, a scan that looked cheap. Benched: ~1.00× with no list operator, ~1.48× on a pathological single $in of 100 members (~74 ns absolute), ~1.08× on a 10-clause compound. One indexOf per list. Measured, not waved through.

Ablation

Both legs, disk-proven, with the step that is usually missed: an ablation-dist-preflight confirming the marker reached executable dist/before the objectql witness pins were run — so their red is a real reading and not a stale-dist artefact. Spec pins 14 red, driver-memory 5 red with 2 controls green, objectql exactly the 5 new tests red and 47 green. Restore proven by git hash-object == HEAD blob and whole-tree git status --porcelain empty, then an --absent preflight over all 215 built files.


Verdict: ACCEPT pending green. Release is blocked on #13656 landing (Test Core (6/6)), plus the contract review that needs:contract-review routes. ⛔ Not marking ready or enqueuing until both.


Generated by Claude Code

@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

CI is green. The sole remaining blocker is the contract review.

My ACCEPT above named two conditions for release. One is now satisfied.

Fully green at 6be6f7a63b — all 33 checks success or skipped, zero failures: Test Core (6/6) ✅ 06:21:43Z, Test Core rollup ✅ 06:30:28Z, all four Type Check legs, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Check Changeset.

Not releasing. Clause ② is settled YES by the ruling itself (「条款②:yes(收窄已发布 filter 契约接受集)」), so this needs the review that needs:contract-review routes, at CONTRACT_REVIEW_TIER. Green alone is not the bar here, and ⛔ this seat does not submit approving reviews.

What the reviewer's attention is best spent on — the one judgment call in the diff, already surfaced rather than buried:

The seat re-judged filter.test.ts's #7596-era openness pin, which literally asserted SetOperatorSchemaaccepts a null member — moving null out of that fixture into an explicit named refusal pin, leaving object / number / Date openness untouched. I adjudicated that ACCEPTED (reasoning in my ACCEPT above): the pin and the ruling are logically contradictory, so stopping would have declared the ruling unimplementable rather than raising a question. It reverts in one hunk if you read it as beyond the line even given the ruling — and the PR then cannot go green, which is the point.

Two earlier reds on this PR are settled and ⛔ should not be re-investigated:


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 07:35
@huangyiirene
huangyiirene added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit e398863Aug 31, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-13357-null-list-comparand-refusal branch August 31, 2026 08:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

3 participants

@zhuangjianguo@huangyiirene@claude