Repository navigation
fix(driver-mongodb)!: refuse a { $field } cross-field reference instead of sending it to MongoDB as a literal (#19949) - #20182
Conversation
… comparand position translateFilter emitted the reference as a literal sub-document, so an RLS using clause such as s != t matched every row. The shape walk now refuses it with the INVALID_FILTER / 400 envelope, field and operator withheld. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…t both doors Also refuses a reference listed in the implicit-equality position, so a list holding one answers the same refusal as $eq with that list. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…owing) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 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 — 6 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 92e10884d995531c08848fdea68308f5275833e2 && git checkout 92e10884d995531c08848fdea68308f5275833e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 455dcc060d5c712cc4fbb161b132827a0900e3ac 00612182e92d42f7efaeb99ee2b51cad01672c68 && git checkout -B drift-repro 455dcc060d5c712cc4fbb161b132827a0900e3ac && git merge --no-ff 00612182e92d42f7efaeb99ee2b51cad01672c68
node scripts/docs-audit/affected-docs.mjs --json 455dcc060d5c712cc4fbb161b132827a0900e3ac |
Contract reviewServed-tier: Scope: PR #20182 for card #19949 ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #19949
Clause-②: no (narrowing)
What changed
translateFilterin@objectstack/driver-mongodbnow refuses a{ $field }cross-field reference in any comparand position. The refusal isINVALID_FILTER/ 400, the envelope every other filter refusal on this driver uses. Before, the driver sent the reference to MongoDB as a literal sub-document.This is the maintainer's ruling B on the card (triage comment 5807932277), quoted verbatim: 「维护者答:「19949 B」。」 The driver refuses the reference and does not implement it: there is no
$exprcolumn-to-column lowering. The driver-mongodb investment freeze (#5499) stays in force for everything else.packages/drivers/driver-mongodb/src/mongodb-filter.ts: one new gate inclassifyFilterKey, on the shape walk that PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 extended. It runs before the other comparand gates, so every reference gets the same refusal whichever operator carries it.carriesFieldReferencechecks three positions: the whole constraint (the bare form, or a list holding a reference), each$-prefixed operator's comparand, and each member of a list comparand. The reference test matches the spec schema door'sisFieldReferenceShape(a non-array object with a$fieldkey), so a malformed{ $field: 42 }is refused too.$ne-array refusal from PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947, because the filter may be an RLS policy the caller did not write.packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts: a new test file for this card, separate from PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947's files..changeset/19949-mongodb-field-reference-refused.md: aminorbump for@objectstack/driver-mongodb, marked BREAKING (accept-set narrowing). The ADR-0087 disposition isnot-required (no-migration-prescription). No stored policy can be converted to keep its meaning, because this driver has nothing to convert it to.Rows: base
3bd28e2b2evs head00612182e9MongoDB selection was read through mingo 7.2.4 as the proxy (the query-semantics library
driver-memoryuses), over the card's rowsr1 {s:'a', t:'a'}andr2 {s:'a', t:'b'}, whererefis{ $field: 't' }. A livemongodis NOT MEASURED: this container cannot fetch the binary, and the package'smongodb-memory-serversuites are opt-in and were skipped. The readings were taken with a scratch script outside the suite, because mingo is not a dependency of this package.s $ne ref(whatrecord.s != record.tlowers to)s $eq ref(whatrecord.s == record.tlowers to)$gt/$gte/$lt/$lteref$in: [ref]$nin: [ref]$between: [ref, 'z']/['a', ref]$and: [s $ne ref]/$or: [s $ne ref, s='zz']$or: [{}, s $ne ref]{}: r1, r2$not: {s $eq ref}(lowered to$nor)$not: {s $ne ref}s $ne {$field:'t', addDays:1}s $eq {$field:'t', addDays:{$field:'n'}}s $ne {$field:42}(malformed)$notContains: ref$contains/$startsWith/$endsWithref$exists: ref$eq: null: none{ s: ref }$field, message names the field)$icontains: ref/$null: ref/$ne: [ref]$norat node levels $ne 'a'/s $eq 'a't $ne 'b'/t $eq 'b't $in ['b']/t $nin ['b']/t $between ['a','a']/$not {t $eq 'b'}The RLS read path fails closed
Measured end to end with a scratch script. The path was
compileCelToFilter, then the realRLSCompiler, the realSecurityPluginwith arowLevelSecuritypolicy (operation: 'all'),ObjectQL, and the realMongoDBDriverover a stubDbwhose collection selects with mingo. The caller is a MEMBER holding the permission set.compileCelToFilter('record.s != record.t')and's != t'both return{ s: { $ne: { $field: 't' } } }.RLSCompiler.compileFilterkeeps it, becausesandtare declared.using: 's != t',findreturned r1, r2,countreturned 2, andfindOne({ id: 'r1' })returned r1, the row the policy excludes. The server received{"s":{"$ne":{"$field":"t"}}}.find,findOneandcounteach throwINVALID_FILTER/ 400, and the collection is asked 0 times. The refusal is not swallowed and the read never falls back to the unfiltered set.using: 's == t'(base: 0 rows, the wrong answer in the fail-closed direction) is also refused.using: 't == "a"gives r1 / count 1,using: 't != "a"gives r2, andusing: 's == "a"gives r1, r2.Every driver door reads
wherethroughtranslateFilter(find,findOne,count,updateMany,deleteMany,aggregateviabuildAggregationPipeline,explain), so the one gate covers them all. The suite pins each door and the engine rethrow. It uses an ObjectQL middleware that composes the policy the way the security middleware does, because@objectstack/plugin-securityis not a dependency of this package.Collateral
$ne-array refusal from PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 is unchanged for literal arrays: its own file and a control in the new file both stay green. A$nearray that holds a reference now gets the cross-field message instead. Same envelope.driver-mongodbalone answers it, as an exact-array match #19757 still holds:translateFilter({ tags: ['a'] })and$eq: ['a']pass through unchanged. The gate only fires when a list member is a reference.{ s: ref }(old message named the field and path),$icontains: ref, and$null: ref. SameINVALID_FILTER/ 400 envelope. No test pinned their old wording for a reference:$fieldhad 0 hits in this package's tests at base.$fieldwith this driver. That was checked with a grep inruntime,service-datasourceandcli. Exports and types are unchanged.Tests at
00612182e9pnpm --filter @objectstack/driver-mongodb test: 28 files passed, 5 skipped (the opt-in live-mongodsuites); 630 tests passed, 147 skipped.pnpm --filter @objectstack/driver-mongodb exec vitest run --maxWorkers=2 src/mongodb-field-reference-refusal.test.ts: 39 passed.pnpm --filter @objectstack/driver-mongodb typecheck:tsc --noEmit, thencheck:test-typecheckwith 0 errors.tsc -p tsconfig.test.json --listFilesincludes the new test file among 33 test files.a53249d5e5.node scripts/ablation-replace.mjsdeleted the gate line (anchor 1 to 0, blob7b33578be43bto343b8c5aa323). With the gate gone, the new file went 33 failed / 6 passed: every refusal red, all six controls green. The tool then restored the file (blob back to7b33578be43b, the HEAD blob, andgit diff HEADempty). Nodist/step was needed, because the suite imports the translator by relative path fromsrc.dist/index.jsloads and refuses, built at00612182e9.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 59 commands from this diff at00612182e9. All 59 were run, and the--ranreconciliation reports 57 run and 2 NOT MEASURED.pnpm check:dual-build-cjs-loadsexited 3: PREREQUISITE NOT MET, because it needs the whole workspace built. NOT MEASURED. Narrowed instead: this package's CJS entry loads, as above.pnpm check:type-check-debtexited 3: PREREQUISITE NOT MET, because the ledgered packages' closure is not built. NOT MEASURED. This package carries no DEBT entry, and its own test-layer typecheck is green.node scripts/check-issue-citations.mjs --base 3bd28e2b2e(exit 0, 5 citations resolve), plus the five artifact-roster gates whose rosters sit under this diff's directories:check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:object-def-param-keysandcheck:tenant-chokepoint, all exit 0.check-changeset-no-majorandcheck-adr-0087-registrationboth pass on the changeset. The first accepts theminorlevel, and the second reads thenot-required (no-migration-prescription)disposition.eslint --no-inline-config --format jsonreports 2 files, 0 errors, 0 warnings. Both files are in the config's population, because--print-configgives each one a rule set. The narrowing cannot hide anything: the config enables no type-aware linting (noparserOptions.projectorprojectService), so this diff cannot change the verdict on any other file. The fullpnpm lintis left to CI.Acceptance notes
$exprlowering). A policy that needs it cannot be enforced on this driver. It is now refused instead of read without the restriction.$existswith a non-boolean comparand still translates to{ $eq: null }("has no value") on this driver, because the arm testsvalue === true. Measured ontranslateFilterat head:'yes',1,nulland'false'all give$eq: null. It has no comparand gate like$null's, and the engine's comparand-type door lists$existsas a scalar operator. Only a reference is refused here. Whether a public door delivers this shape, and what the other drivers answer, was not measured. It is left alone under the driver-mongodb freeze. Carrier: none.Generated by Claude Code