Skip to content

[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

@os-zhuang

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. So where: { 'project_id.name': 'Apollo' } clears the unknown check because project_id is 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:

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).

Activity

  1. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage: kept as finding, routed domain:metadata (the verdict — if one is ever added — lands in packages/metadata-protocol's assertFilterFieldsExist, same door as #7534/#8296; the prerequisite is a three-driver measurement, which the card itself says is the right first step). Not promoted to pm: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

  2. added theissue type on Aug 13, 2026
  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Concentrated findings round (maintainer-directed, 2026-08-13): promoted finding → pm:queue, scoped to the MEASUREMENT only (domain:metadata unchanged). 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 a domain:spec semantic call (fable tier) per the standing rule.

    Note: measurement touches no product code, so the hot-protocol.ts serial 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

  4. self-assigned this
    on Aug 15, 2026
  5. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    Claiming for the measurement (dev seat, session session_01XeQRiAa7vYRVX5Fog7Zby8, branch claude/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

  6. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    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_id is a lookup, so it stores the related row's scalar id — SQL gives it table.string(name) plus a FK; Mongo indexes it with a single-key index on a scalar field. is_open is a formula (virtual, no column anywhere). address is 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 where driver-memory driver-sql driver-mongodb
    A relation head { 'project_id.name': 'Apollo' } 0 rows 0 rows (silent) emits {"project_id.name":"Apollo"} -> 0 docs
    D 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=0 THREW SQLITE_ERROR, no status n/a
    H A + fields + orderBy ladder 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 on count. 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.name
    

    find() catches that as the #3821 unknown-column case, but the recovery ladder cannot reach a WHERE: both retries are built from buildBase(), which always re-applies query.where. Projection retry fails, ORDER BY retry fails, recovered stays false, and the method falls to return [] (probe H exercises both rungs explicitly and still lands on 0 rows). count() has no ladder at all, so the identical predicate throws a raw SQLITE_ERROR with no ADR-0112 envelope (no status, 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_id is 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. translateFilter emits {"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 plainly

    A live mongod could 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 403
    

    This is the same constraint this repo already records for the mongod family (#5517; mongodb-pipeline-evaluator.testkit.ts states 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': …} where project_id holds 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 drivers

    The 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_FIELD 400 INVALID_FIELD 400
    E2 { nosuchfield: 'x' } (undotted) INVALID_FIELD 400 0 rows (this seam judges materializability only)
    F { 'nosuchfield.name': 'x' } INVALID_FIELD 400 0 rows

    Two things worth naming:

    1. 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.
    2. The dotted spelling evades the verdict its undotted twin already has. { is_open: true } is refused (The FILTER axis has no unmaterializable verdict: a where on 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 [] while count() 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.city capability 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-virtual split (#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 driver supports surface. 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.city on 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_open refused, is_open.x not). 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.city stops working on driver-memory and driver-mongodb.

    Per the standing rule this is a domain:spec semantic 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 (SqlDriver on better-sqlite3 :memory:, InMemoryDriver, translateFilter + mingo) and the same probes through new ObjectQL() with each real driver registered plus ObjectStackProtocolImplementation for the door row. Nothing in them is mocked.


    Generated by Claude Code

  7. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor
    {
      "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 inside driver-memory / driver-mongodb under the 2026-08-05 freeze (#5499) - they were measured only; no PR at all, since nothing needed to land.


    Generated by Claude Code

  8. 17 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions