Repository navigation
[finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371
Description
Activity
Triage: kept as
finding, routeddomain:metadata(the verdict — if one is ever added — lands inpackages/metadata-protocol'sassertFilterFieldsExist, same door as #7534/#8296; the prerequisite is a three-driver measurement, which the card itself says is the right first step). Not promoted topm:queue: the author explicitly frames it as an observation with the driver half unmeasured, and the sibling card #8296 / PR #8369 is still in flight on the same doors — any gate here would touch the same code. Re-grade at a findings-triage round after #8296 lands and the measurement exists.
Generated by Claude Code
Concentrated findings round (maintainer-directed, 2026-08-13): promoted
finding→pm:queue, scoped to the MEASUREMENT only (domain:metadataunchanged). Type: Task.The morning hold's re-grade trigger has fired: #8296 is landed (its changeset and follow-on cards are already on main), so the sibling-card collision is gone. What remains unmeasured is exactly what the card says: three drivers, dotted filter key, three possibly different answers.
Graded scope — a measurement card, ⛔ not a gate: drive
where: { 'project_id.name': … }(relation head) and a formula/system-column dotted spelling through driver-sql, driver-mongodb, driver-memory at a real engine; record verdict per driver (rows returned / error class / silent-empty), including driver-sql's #3821 recovery-ladder outcome. Deliverable = the measured table on this card + a recommendation (refuse like SORT #4256 / capability declaration / per-driver verdict), each option tagged with which sibling precedent it follows. The verdict decision itself comes back through triage — if the recommendation changes acceptance behaviour, it is adomain:specsemantic call (fable tier) per the standing rule.Note: measurement touches no product code, so the hot-
protocol.tsserial constraint does not bind it; mind the memory/mongodb investment freeze only insofar as this card measures them, which is allowed — it builds nothing on them.Size/model suggestion: M (measurement discipline, no design),
mode:subagent, opus.
Generated by Claude Code
Claiming for the measurement (dev seat, session
session_01XeQRiAa7vYRVX5Fog7Zby8, branchclaude/issue-8371-filter-dotted-path-measure).Working the scope graded in the 2026-08-13 findings-round comment: the three-driver measurement only, ⛔ no gate. Both dotted spellings (relation head + formula/system-column) at a real engine per driver,
driver-sql's #3821 recovery-ladder outcome included, each option tagged with its sibling precedent.
Generated by Claude Code
Measurement: what the three drivers actually do with a dotted filter key
Scope as graded on 2026-08-13: the measurement only, no gate. Real drivers, real
find(), real fixture, controls on every claim. Verdict decision left to triage.Fixture
One shape in every backend.
project_idis alookup, so it stores the related row's scalar id — SQL gives ittable.string(name)plus a FK; Mongo indexes it with a single-key index on a scalar field.is_openis aformula(virtual, no column anywhere).addressis a structured JSON value ({ city: 'Beijing' }), i.e. a genuinely nested stored value, which turns out to be the case that decides this card.project: p1 "Apollo", p2 "Gemini" task: t1 Design/open/p1/{city:Beijing} t2 Build/open/p1/{city:Shanghai} t3 Ship/done/p2/{city:Beijing}Driver level — real
find()probe wheredriver-memory driver-sql driver-mongodb A relation head { 'project_id.name': 'Apollo' }0 rows 0 rows (silent) emits {"project_id.name":"Apollo"}-> 0 docsD formula head { 'is_open.x': true }0 rows 0 rows 0 docs E system-column head { 'created_by.name': 'Ada' }0 rows 0 rows 0 docs F unknown head { 'nosuchfield.name': 'x' }0 rows 0 rows 0 docs I nested stored value { 'address.city': 'Beijing' }2 rows [t1,t3] 0 rows 2 docs [t1,t3] B CONTROL { project_id: 'p1' }2 rows [t1,t2] 2 rows [t1,t2] 2 docs [t1,t2] C CONTROL { title: 'Design' }1 row [t1] 1 row [t1] 1 doc [t1] G count()of A{ 'project_id.name': … }count=0THREW SQLITE_ERROR, nostatusn/a H A + fields+orderByladder exercised n/a 0 rows n/a The B/C controls are what make every "0 rows" above readable: the same fixture, same call, undotted, returns rows.
The answer per driver, and the mechanism
driver-sql — silent zero on
find, raw throw oncount. knex reads the dot as a table qualifier, so the key is emitted as a qualified column that no dialect can resolve:select count(*) as `count` from `task` where `project_id`.`name` = 'Apollo' -> no such column: project_id.namefind()catches that as the #3821 unknown-column case, but the recovery ladder cannot reach a WHERE: both retries are built frombuildBase(), which always re-appliesquery.where. Projection retry fails, ORDER BY retry fails,recoveredstays false, and the method falls toreturn [](probe H exercises both rungs explicitly and still lands on 0 rows).count()has no ladder at all, so the identical predicate throws a rawSQLITE_ERRORwith no ADR-0112 envelope (nostatus, and the bound literal inlined in the message). That find-vs-count contradiction is independent of whatever verdict this card takes, so it is filed on its own as #8790.driver-memory — silent zero. The key is passed through to mingo verbatim; mingo resolves the dotted path against the row, where
project_idis the string'p1', so nothing matches.count()agrees (count=0) — this driver is at least self-consistent.driver-mongodb — passes the key through untouched, and it matches nothing.
translateFilteremits{"project_id.name":"Apollo"}verbatim: no refusal, no rewrite. Mongo's dotted paths traverse embedded documents, and an ObjectStack lookup is never an embedded document — it is a scalar id with a single-key index. So the spelling is well-formed Mongo that matches zero documents.⚠️ The bound on the Mongo row, stated plainlyA live
mongodcould not be obtained in this container and I did not fabricate one. Attempted via the sanctioned opt-in path (OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1); the download is refused by the egress proxy:Download failed for url "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2404-8.2.6.tgz" Status Code is 403This is the same constraint this repo already records for the mongod family (#5517;
mongodb-pipeline-evaluator.testkit.tsstates it in its own header). So the Mongo column above is measured at two real levels — the production translator's actual output, and that output evaluated by mingo, an independent implementation of MongoDB's documented query semantics that is already a dependency of driver-memory — and not against a live server. The structural half of the claim needs no server and is not in doubt: a lookup stores a scalar id in every backend, and a dotted path into a scalar matches nothing.What would overturn the Mongo row: a live mongod returning rows for
{'project_id.name': …}whereproject_idholds a scalar string. If that is thought possible, this one probe should be re-run on a runner that can fetch a binary before the verdict is settled. Nothing else in this measurement depends on it.End to end — both doors, real
ObjectQL+ real driversThe card's "rides its head segment past both doors" is now measured, not only read:
probe protocol door ( findData)engine seam ( ObjectQL.find)A { 'project_id.name': 'Apollo' }0 rows, 200 0 rows D { 'is_open.x': true }0 rows, 200 0 rows D2 { is_open: true }(undotted)INVALID_FIELD400INVALID_FIELD400E2 { nosuchfield: 'x' }(undotted)INVALID_FIELD4000 rows (this seam judges materializability only) F { 'nosuchfield.name': 'x' }INVALID_FIELD4000 rows Two things worth naming:
- The head-segment rule works exactly as documented — F is refused because its head is unknown, and the message quotes the whole dotted key. Only a dotted key with a real head rides through.
- The dotted spelling evades the verdict its undotted twin already has.
{ is_open: true }is refused (The FILTER axis has no unmaterializable verdict: awhereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296);{ 'is_open.x': true }answers 200 with zero rows. One unserviceable intent, two answers, decided by spelling.
What this establishes
- On the spelling this card is named after, the three drivers AGREE: relation-head, formula-head, system-column-head and scalar-head dotted filters all answer zero rows on all three backends. The card's worry that "a refusal could remove a working feature" is not supported for the relation case — there is no working feature there to remove, on any backend, including Mongo.
- The drivers DISAGREE on exactly one spelling, and it is not the one the card predicted: a dotted path into a nested stored value (
address.city) really works on driver-memory and driver-mongodb (2 rows) and silently returns nothing on driver-sql. That is where a blanket refusal would delete a live capability. - Every "zero rows" here is silent — 200, empty list, indistinguishable from an empty table. The one non-silent answer is driver-sql's
count(), and it is unenveloped (driver-sql: one unresolvable WHERE column, two answers —find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790).
Options, each tagged with the precedent it follows
Option 1 — refuse every dotted key on the FILTER axis, at both doors. Precedent: SORT #4256, PROJECTION #7589 (one axis, one verdict, both doors). Cheapest and most symmetric. Cost, now measured: it deletes the working
address.citycapability on two of three backends.Option 2 — type-directed verdict on the head segment: refuse when the head is a relation, a virtual, or a plain scalar; leave the structured/JSON head unjudged for now. Precedent: #8296's own precedence ladder on this very axis (identity errors before type errors) and the SEARCH axis'
unknown-vs-virtualsplit (#6674). Refuses exactly the set the measurement shows no backend can serve, and touches nothing that works today. The formula-head case is not even a new verdict — it is #8296's existing verdict finally reaching the spelling that evades it.Option 3 — capability declaration. The card's hypothesis, but relocated by the measurement: the capability worth declaring is JSON-path filtering (
address.city), not relation traversal. Precedent: the driversupportssurface. Real work, and it should follow a measured pull rather than precede it.Option 4 — leave it and document. Precedent: none on this family; #4181 / #4254 / #7534 / #8296 each closed one axis rather than documenting it.
Recommendation: Option 2, with the JSON-path head deferred to Option 3 only if a real consumer appears
Real business need. Measured, not inferred: no backend serves a relation-head, formula-head, system-column-head or scalar-head dotted filter — all three return zero rows. So Option 2 removes no capability from anyone; it renames a silent wrong answer into a loud one. The one live capability found (
address.cityon memory and mongodb) is explicitly not touched. Under the startup-focus principle, building a cross-backend JSON-path capability (Option 3) with no measured consumer is capability expansion ahead of pull, and driver-sql would need real work to reach parity — so it should wait for a caller that actually wants it.Long-term soundness for this project. A filter key that no backend can honour answering 200-with-zero-rows is precisely the fail-open shape #4181 / #4254 / #7534 / #8296 each closed one axis over, and this axis now has the sharper embarrassment of answering one unserviceable intent two ways depending on spelling (
is_openrefused,is_open.xnot). Option 2 is contract-first and adds no mechanism: it is the existing door, the existing error identity (INVALID_FIELD/ 400), the existing remedy sentence SORT #4256 already prescribes (denormalise onto a stored field). Option 1 would be more symmetric but buys that symmetry by deleting working behaviour, which is a workaround wearing a principle's clothes.Making AI-written metadata hard to get wrong. This is the axis that decides it. A dotted filter path is exactly what an AI author writes by analogy with
$expand, with projection spellings, and with SQL joins — and today it returns an empty list, which in a generated app is indistinguishable from "no matching records" and is the single hardest failure mode to notice. Worse, the value often reads correctly in the same response. A refusal at authoring/publish time that names the offending key path converts a silent wrong answer into an error the author cannot miss, which is the whole argument of Prime Directive #12 applied to a query surface.Where the three axes conflict: only on the structured-JSON head. Soundness says one axis should have one verdict (favouring Option 1); the business-need axis says do not delete a capability two backends really serve. I have resolved that by not deciding it — Option 2 leaves the JSON head unjudged and says so, rather than sweeping it into a refusal for tidiness. If the maintainer prefers one verdict for the whole axis, Option 1 is coherent and the cost is now quantified:
address.citystops working on driver-memory and driver-mongodb.Per the standing rule this is a
domain:specsemantic call (it changes acceptance behaviour on a published query surface), so it goes back through triage rather than being implemented here.Reproduction
Two standalone scripts, no repo changes. Build the driver closures first (
pnpm --filter '@objectstack/driver-sql...' --filter '@objectstack/driver-memory...' --filter '@objectstack/driver-mongodb...' --filter '@objectstack/metadata-protocol...' build), then run the drivers directly (SqlDriveron better-sqlite3:memory:,InMemoryDriver,translateFilter+ mingo) and the same probes throughnew ObjectQL()with each real driver registered plusObjectStackProtocolImplementationfor the door row. Nothing in them is mocked.
Generated by Claude Code
{ "issue": 8371, "status": "needs_decision", "branch": "claude/issue-8371-filter-dotted-path-measure", "pr": null, "premise_still_valid": true, "summary": "The graded scope (measurement only, no gate) is complete and posted as the measured table on this card. The gap premise holds: FILTER still has no dotted verdict, and a dotted key with a real head rides past both doors - measured end to end, not just read. The card's driver-divergence HYPOTHESIS is partly falsified and partly relocated, which is what the measurement was for. On the spelling the card is named after the three drivers AGREE: relation-head, formula-head, system-column-head and scalar-head dotted filters all return zero rows on driver-memory, driver-sql and driver-mongodb. Mongo does NOT serve a relation-head dotted path, because an ObjectStack lookup stores a scalar id (SQL: table.string + FK; Mongo: single-key index on a scalar) and Mongo's dotted paths traverse embedded documents - so there is no working feature a refusal would remove there. The drivers DO disagree, on a spelling the card did not name: a dotted path into a nested STORED value ('address.city') really works on driver-memory and driver-mongodb (2 rows) and silently returns nothing on driver-sql. That is the only place a blanket refusal deletes live behaviour, and it is what my recommendation turns on. No production code was written and no gate was built, per the card and the dispatch.", "tests": "No production code changed, so no gate union is owed and none is claimed; the tree is unchanged from origin/main at 84cb121eb (worktree removed clean, zero commits). What was run instead, all real drivers and real find(), never mocks. Build closure first: pnpm --filter '@objectstack/driver-sql...' --filter '@objectstack/driver-memory...' --filter '@objectstack/driver-mongodb...' --filter '@objectstack/metadata-protocol...' build -> Done, exit 0. DRIVER LEVEL (SqlDriver on better-sqlite3 :memory:, InMemoryDriver, translateFilter + mingo), probe A where {'project_id.name':'Apollo'} -> memory '0 rows []', sql '0 rows []', mongodb emits {\"project_id.name\":\"Apollo\"} -> '0 rows []'; CONTROLS on the same fixture and same call: B where {project_id:'p1'} -> '2 rows [t1,t2]' on all three, C where {title:'Design'} -> '1 rows [t1]' on all three, so no 'zero rows' above is an empty-fixture artifact. Probe I where {'address.city':'Beijing'} -> memory '2 rows [t1,t3]', mongodb '2 rows [t1,t3]', sql '0 rows []' - the one divergence. driver-sql #3821 ladder outcome, measured explicitly: find() 'G count() of probe A -> THREW code=SQLITE_ERROR status=- :: select count(*) as `count` from `task` where `project_id`.`name` = 'Apollo' - no such column: project_id.name' while find() on the identical predicate returns 0 rows silently; probe H (where + fields + orderBy, which forces BOTH ladder rungs) still '0 rows' because every retry rebuilds from buildBase() and re-applies the WHERE. ENGINE + BOTH DOORS (real ObjectQL, real drivers, ObjectStackProtocolImplementation): protocol door refuses {is_open:true} and {nosuchfield:'x'} and {'nosuchfield.name':'x'} with code=INVALID_FIELD status=400, and passes {'project_id.name':...} and {'is_open.x':true} through to 0 rows / 200; engine.count on probe A throws the same raw SQLITE_ERROR while engine.find answers 0 rows. MONGO BOUND, stated rather than papered over: a live mongod is unobtainable in this container - attempted the sanctioned opt-in path OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1 and got 'Download failed for url https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2404-8.2.6.tgz, Status Code is 403'; docker has no daemon. Same constraint #5517 and mongodb-pipeline-evaluator.testkit.ts already record for this fleet. So the Mongo column is measured at the real production translator plus mingo (an independent implementation of documented Mongo query semantics, already a driver-memory dependency) and NOT against a live server - the report says exactly which link is unmeasured and what would overturn it. No ablation was involved, so there is no rebuild claim to make.", "open_questions": [ { "question": "What verdict should the FILTER axis give a dotted filter key? This changes acceptance behaviour on a published query surface, so per the standing rule it is a domain:spec semantic call and not mine or the PM's.", "options": [ "Option 1 - refuse every dotted key at both doors (precedent: SORT #4256, PROJECTION #7589; one axis, one verdict). Cheapest and most symmetric. Measured cost: deletes the working 'address.city' capability on driver-memory and driver-mongodb.", "Option 2 - type-directed verdict on the head segment: refuse when the head is a relation, a virtual, or a plain scalar; leave the structured/JSON head unjudged (precedent: #8296's own unknown-before-type precedence ladder on this axis, and the SEARCH axis' unknown-vs-virtual split #6674). Refuses exactly the set no backend can serve; touches nothing that works today.", "Option 3 - capability declaration, but relocated by the measurement: the capability worth declaring is JSON-path filtering ('address.city'), not relation traversal (precedent: the driver supports surface). Real work; driver-sql would need parity.", "Option 4 - leave it and document (precedent: none on this family; #4181 / #4254 / #7534 / #8296 each closed an axis rather than documenting it)." ], "recommendation": "Option 2, with the JSON-path head deferred to Option 3 only if a real consumer appears. Business need: measured, no backend serves a relation-head, formula-head, system-column-head or scalar-head dotted filter, so Option 2 removes no capability from anyone - it only makes a silent wrong answer loud - while explicitly not touching the one live capability found; under the startup-focus principle a cross-backend JSON-path capability with no measured pull should wait for a caller. Long-term soundness: a filter key no backend can honour answering 200-with-zero-rows is the fail-open shape #4181 / #4254 / #7534 / #8296 each closed one axis over, and this axis now answers one unserviceable intent two ways by spelling ({is_open:true} refused, {'is_open.x':true} not); Option 2 adds no mechanism - same door, same INVALID_FIELD/400 identity, same denormalise-onto-a-stored-field remedy SORT #4256 already prescribes. AI-authored metadata: a dotted filter path is exactly what an AI author writes by analogy with $expand, projection spellings and SQL joins, and today it returns an empty list that is indistinguishable from 'no matching records' in a generated app - the hardest failure to notice, especially since the value often reads correctly in the same response. The three axes conflict only on the structured-JSON head, and I resolved that by NOT deciding it rather than sweeping it into a refusal for tidiness; if the maintainer prefers one verdict for the whole axis, Option 1 is coherent and its cost is now quantified." }, { "question": "Should the one unmeasured link be closed before the verdict is settled - a live mongod answering {'project_id.name': ...} where project_id holds a scalar string?", "options": [ "Accept the current evidence (production translator output + mingo + the structural fact that a lookup is a scalar id in every backend) and settle the verdict now.", "Re-run that single probe on a runner that can fetch a mongod binary before settling, since #5517 froze that download for this fleet." ], "recommendation": "Accept and settle now. The structural half needs no server - a lookup stores a scalar id, and Mongo's dotted paths traverse embedded documents, not cross-collection references - and nothing else in the measurement depends on that link. I flagged it explicitly rather than quietly presenting it as a live-server result." } ], "out_of_scope_findings": [ "filed as #8790: driver-sql answers one unresolvable WHERE column two ways - find() silently returns [] because the #3821 recovery ladder rebuilds from buildBase() and can never drop the failing WHERE, while count() has no ladder and throws a raw dialect error (code SQLITE_ERROR, no status, bound literal inlined) with no ADR-0112 envelope. Independent of this card's verdict, survives every remedy, and reachable by any unknown WHERE column - filed unassigned and unlabeled for triage, not fixed here." ] }Deliberately NOT done, so the next reader does not have to infer it: no gate, no refusal, no capability declaration, no characterization test pinning today's behaviour (that would be a soft gate pre-empting the accept-set decision); no edits to
packages/metadata-protocol/src/protocol.ts(two other agents are in that file); nothing fixed insidedriver-memory/driver-mongodbunder the 2026-08-05 freeze (#5499) - they were measured only; no PR at all, since nothing needed to land.
Generated by Claude Code
17 remaining items
- added 3 commits that reference this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 2, 2026 - added a commit that references this issue
on Sep 17, 2026 - added 7 commits that reference this issue
on Oct 7, 2026
Filed from PR #8369 (#8296) as an observation, not a defect claim — the driver-side half is genuinely unmeasured and one backend may support the spelling natively. No
pm:queue.The gap, as a code fact
assertFilterFieldsExist(packages/metadata-protocol/src/protocol.ts) judges a filter key on its head segment only — deliberately, and its docblock says why:{ owner: { region: 'NA' } }is a legitimate nested-relation condition whose inner keys belong to a different object. Sowhere: { 'project_id.name': 'Apollo' }clears the unknown check becauseproject_idis a real field, and #8369's new virtual verdict deliberately skips dotted keys too (documented in both doors' scope notes: this axis has one verdict, and inventing a dotted one for the formula-headed case alone would answer two spellings of one unjudged shape differently).The SORT axis does not leave it there. #4256 gave it a dedicated verdict — a dotted path is refused because "sort reaches only columns of this object itself", with a denormalise-onto-a-stored-field remedy — and #7589 did the same for the PROJECTION axis at both doors. FILTER is now the only one of the four axes with no answer for a dotted name.
What is NOT established
Whether this is a defect at all depends on the backend, and that is the part nobody has measured:
driver-mongodbsupports dotted paths natively ({'owner.region': 'NA'}is ordinary Mongo), so a refusal could remove a working feature;driver-sqlrenders it as a column against a table that was never joined — the fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821 "no such column" ladder, whose recovery path decides whether the answer is zero rows, every row, or an error;driver-memoryresolves the path against the row itself, where a foreign key is a scalar id.Three backends, possibly three answers, which is the same shape #5869 documented for comparands — but unlike that one it has not been measured here. The right first step is a measurement across the three drivers, not a gate.
Why file it now
The three sibling axes each closed this and the FILTER one was never asked. If the drivers really do disagree, this is the fail-open shape #4181 / #4254 / #7589 each closed one axis over; if Mongo genuinely serves it, the answer may be a capability declaration rather than a refusal. Either way the question deserves to exist somewhere other than a scope note inside a docblock.
Sibling context: #4256 (sort, dotted), #7589 (projection, dotted, both doors), #7534 (filter, unknown), #8296 (filter, unmaterializable).