Uh oh!
There was an error while loading. Please reload this page.
fix(analytics): refuse a { $field } comparand on both SQL-lowering doors instead of binding it (#7598) - #7694
Conversation
…doors instead of binding it (#7598) Measured on origin/main (5823d59), the premise of #7598 was inverted: neither door refused a field reference in a scalar comparand position — both BOUND the reference object as the comparison's value, producing a syntactically perfect predicate comparing a column against a value no row can hold. On the read-scope door that is an administrator's RLS predicate silently answering the wrong row set. Both doors now refuse, each in its existing envelope (INVALID_FILTER / 400 on the analytics `where` door, READ_SCOPE_COMPILE_FAILED / 500 on the read-scope lowering). Positions that already refused keep their exact wording, because each of those converges with driver-sql's own #5222 refusal arm. This does NOT port the #5222 capability: its four maintainer rulings turn on an object's declared field set and its tenant-isolation column, neither of which StrategyContext exposes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
…al in writing The changeset declares a breaking change (`fix(analytics)!`), so check-adr-0087-registration requires the ledger question be answered. Disposition: not-required (no-migration-prescription) -- no authorable key is retired, packages/spec is untouched, and the FROM shape stays valid metadata that still executes on the ObjectQL engine path and both SQL drivers, so there is nothing for `objectstack migrate meta` to rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy
Uh oh!
There was an error while loading. Please reload this page.
Part of #7598
⛔
Part of, notFixes: the card asks for the #5222 capability to be PORTED to these two compilers, and this PR does not port it — it closes the silent defect the port's premise-check uncovered and leaves the port itself in the decision box (see "What this does not do"). Merging it should not close the card.#7598 reads: "
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces."Measured on
origin/main@5823d593d, nothing 400s. For the six scalar comparison operators — exactly the ones #5222 taughtdriver-sqlto compile into a same-table column-to-column comparison — both doors COMPILED, and put the reference object in the bind list:{ amount: { $gt: { $field: 'budget' } } }read-scope-sql"person"."amount" > ?· bind list[{"$field":"budget"}]where→NativeSQLStrategyWHERE amount > $1· bound to the JSON TEXT{"$field":"budget"}where→/analytics/sqlechoWHERE amount > $1· bound to the reference OBJECTwhere→ ObjectQL engine{amount:{$gt:{$field:'budget'}}}reachesdriver-sql, which compiles it correctly since #5222So the defect was never a refusal — it was a silent wrong answer: a syntactically perfect predicate comparing a column against a value no row can hold. Three of the four faces disagreed, and the one answering wrongly on the read-scope door is an administrator's RLS predicate, which is why that half is graded above the
wheredoor's empty chart.The gates assumed to be catching this (
isBindableComparand/isRenderableTextComparand) had not drifted fromdriver-sql— they are simply never ASKED about that position. Both doors consult them for the LIKE family and for$in/$nin/$betweenMEMBERS only, so the whole comparand of a scalar comparison was unguarded on both.Two corollaries worth stating, because the card predicted otherwise:
comparand-shape-refusal.test.tsis{name: {$contains: {$field: 'status'}}}— a LIKE-family operator, which [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 left in its own refusal arm (a column-side LIKE pattern cannot be metacharacter-escaped portably). That pin still converges withdriver-sql, verbatim, and is deliberately unchanged. Only its comment gains the re-measurement.$betweenon thewheredoor was the one comparand position no shape gate had ever seen — its branch infieldLeaveslowers togte/lteleaves and returns beforeassertCompilableComparandruns.What this PR does
Both doors refuse a
{ $field }comparand whose value is a string, in the positions that were BOUND — the six scalar comparison operators' whole comparand, plus the two$betweenendpoints — each in its existing envelope:INVALID_FILTER/ 400 on the analyticswheredoor,READ_SCOPE_COMPILE_FAILED/ 500 on the read-scope lowering.comparand-shape.tsgains the third question (isFieldReference) and owns the shared sentence, exactly as it already owns the other two; each door keeps its own throw.Positions that already refused keep their exact wording, because each of those refusals already converges with
driver-sql's own #5222 refusal arm.read-scope-sql's header goes from twelve refusing sites to thirteen.The reference shape is read exactly as
driver-sql'sfieldReferenceOfreads it: extra keys do not disqualify it (formula'sresolveValueignores them too), and a$fieldwhose value is not a string is NOT a reference — it stays the ordinary object-comparand account #5234 deliberately left open.It does not bring the capability to these compilers.#5222's four maintainer rulings (2026-08-06) — same-table only, declared-only enumeration, tenant-isolation column forbidden on both sides, same comparison class — all turn on metadata
driver-sqlreads from its owninitObjectscapture.StrategyContext(@objectstack/spec/contracts) exposes neither an object's declared field set nor its tenant-isolation column, so these compilers cannot enforce the rulings at all, and the dispatch's own instruction for that case is to stop rather than ship a weaker port. Implementing it here is apackages/speccontract question first, which is out of this card's declared surface.Refusing at the
wheredoor narrows the ObjectQL engine path, which today passes the reference through to a driver that handles it properly. That cost is taken deliberately and is the part to re-open if the maintainer rules otherwise: the pass-through is an accident ofconvertFilterforwarding an unrecognised comparand (nothing here validates it, no test pinned it), and leaving it makes one authoredwheremean two things depending on the backend behind the cube — with the other two emitters unable to agree either way.The mandated envelope correction is NOT made. The dispatch requires that an unsupported rule stop presenting as a server error on
read-scope-sql(4xx + locating information). That conflicts head-on with the #5367 maintainer ruling of 2026-08-06, quoted verbatim and untranslated from the module header, which rejected a 4xx including a 422:Both of that ruling's arguments apply to this shape verbatim rather than by analogy: the producer is an admin-authored sharing rule and its CEL lowering (
compileCelToFilteris exactly what emits{ $field: path }), so a 4xx bills a caller who cannot author the document; and a 4xx echoes the message, which here names the POLICY's field names — the disclosure #5367 closed. Re-adjudicating a maintainer ruling is not this seat's to do, so it is raised as an open question instead.Verification
@objectstack/service-analytics: 72 files / 1551 tests passed (1444 before — the new file adds 107). ESLint clean on every changed file with--no-inline-config.check:nul-bytesOK (7091 files, self-test 75 assertions),check-changeset-fixedOK (69 public packages),check:docs-audit-scopeOK,pnpm install --frozen-lockfileclean.CROSS_FIELD_CASESandCROSS_FIELD_REFUSALSare imported from@objectstack/driver-sql(new devDependency only —service-automationsets the precedent; no runtime edge added). Every case in both arms is refused on both doors, which is the asymmetry [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 records, stated as an executable fact.messageIncludesare deliberately not asserted — those pindriver-sql's wordings, and a driver message naminginitObjectsdeclarations would be a lie on this side. The envelope is asserted for every case; the wordings are asserted separately, per door.Reverse verification — direction predicted in writing before running
Both
assertNoFieldReferenceComparandcall sites disabled. Predicted: the scalar /$betweencells go red by resolving (not by some other throw); the LIKE-family and list-member cells stay green;comparand-shape-refusal.test.tsstays fully green.Measured: 87 failed / 47 passed, and every failure reads
expected the compiler to refuse this filter, but it returned …— the predicted cells, failing in the predicted manner. On a targeted re-read: not one LIKE-family or$in/$nincase went red, proving the gate is narrow and those refusals come from the #5234 gates; the two$between-endpoint cases went red on thewheredoor only, staying green on the read-scope door whereassertCompilableMembersalready refused them — which is precisely why$betweenhad to be named on thewheredoor; andcomparand-shape-refusal.test.tsstayed green in full.Restored by inverse edit inside this worktree (no
git stash);grepconfirms zero markers remain and the suite is green again.Out of scope, filed
$icontainsis absent from analyticsTEXT_PATTERN_OPERATORS, so the #5234 comparand fence never covered it on thewheredoor — one operator, two answers inside one package #7693 —$icontainsis absent fromTEXT_PATTERN_OPERATORS, so the driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234 comparand fence never covered it on thewheredoor:{name: {$icontains: {foo: 1}}}still compiles toLIKE '%[object Object]%'there whileread-scope-sqlrefuses it. Pinned as a RECORDED GAP block in the new suite so it shows in test output instead of being re-discovered. Same class as objectqlhavinghas no$icontainscomparand-shape gate — an empty comparand matches EVERY row (2 of 5FILTER_TEXT_CASESrejection rows unenrollable) #7158 at a third face.Generated by Claude Code