You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[spec] service-analytics' read-scope / Cube filter compilers still refuse $field, so a CEL field-to-field RLS rule 400s on those faces #7598
Measured while implementing #5222 (cross-field push-down). Filing unassigned — recording, not claiming.
The fact
#5222 made driver-sql and driver-sqlite-wasm compile { $field: 'col' } into a same-table column-to-column comparison for the six scalar operators. It did not reach service-analytics, whose two filter compilers are independent emitters:
read-scope-sql.ts — the RLS read-scope lowering
strategies/filter-normalizer.ts — the Cube face
Both gate comparands through comparand-shape.ts's isBindableComparand, a deliberate hand-mirror of driver-sql's module-private predicate (the file says so, and __tests__/comparand-shape-refusal.test.ts pins {$field: …} as refused "converging with driver-sql"). A { $field } object is not bindable, so it is still refused there.
Why it matters
This is the same defect #5041 named, at a different face. compileCelToFilter emits { $field: path } for a field-to-field comparison in a CEL permission/RLS rule. After #5222 that rule executes on the SQL drivers and in memory — but a read scope lowered through read-scope-sql still answers INVALID_FILTER / 400.
So the capability is now asymmetric across SQL-lowering faces: one rule, two outcomes, chosen by which compiler the request went through. That asymmetry is narrower than the one #5222 closed, but it is the same shape.
Note the convergence comment those tests carry is now stale in one direction: it says refusing $field converges with driver-sql, and as of #5222 it diverges from it for the six scalar operators.
What the work looks like
The #5222 rulings port directly — same-table columns only, declared-only enumeration, tenant-isolation column forbidden on both sides, same comparison class, and the same total-across-NULLs predicate shape. What each face needs its own answer for is where the declared-field set comes from: the driver reads its own initObjects capture, and these compilers do not have one.
The conformance corpus is reusable as-is — it is exported from @objectstack/driver-sql as CROSS_FIELD_CASES / CROSS_FIELD_ROWS / CROSS_FIELD_REFUSALS precisely so a second face can be held to the same table.
Scope note: driver-mongodb and driver-turso REMOTE are also unconverted; they were out of #5222's ruling scope too, and are the reason its corpus was deliberately not promoted into packages/spec/src/data (doing so would enrol them as DEBT rows in the driver-conformance matrix).
Routing: every named fix site lives in packages/services/service-analytics — src/strategies/filter-normalizer.ts, the read-scope lowering, src/__tests__/comparand-shape-refusal.test.ts (all verified present on origin/main @ 744b8f5). The card explicitly keeps packages/spec untouched (the corpus stays exported from @objectstack/driver-sql, deliberately not promoted into spec), so the acceptance surface does not move — anchoring rule says the label follows the package the fix lands in: packages/services/* ⇒ domain:services. The filer-applied domain:spec was a reasonable first guess but doesn't match the landing.
Classification: queue, not decision — "The [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 rulings port directly" (same-table only, declared-only enumeration, tenant column forbidden, same comparison class); the one open point (where each compiler's declared-field set comes from) is an implementation question inside the port, not a contract fork.
⚠️ Dispatch gate for the claiming seat: [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222's PR has not merged — CROSS_FIELD_CASES has zero hits on origin/main @ 744b8f5 (counter-probe: isBindableComparand and FieldReferenceSchema grep fine), so the reusable corpus this card builds on is not on main yet. Treat this as blocked-in-fact on the [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 PR landing; verify the export exists at dispatch time. Not marking pm:blocked because the machine half requires a body edit, which this seat avoids (sanitizer risk) — this comment is the record.
Cross-link from triage: #7604 (filed 07:22Z by the spec-lane PM, same residue — read-scope-sql + filter-normalizer refusing $field) is closed as duplicate of this card. Its three disposition questions are required reading at dispatch, especially the envelope point: read-scope-sql refuses via READ_SCOPE_COMPILE_FAILED / 500 (not INVALID_FILTER / 400), and whichever way this card lands, that path must stop presenting an unsupported rule as a server error.
Claim: PM loop, services lane, wave 2 round 4
Session: session_015fkdTyGmMD5s8ZtEifvuGy
Branch: claude/issue-7598-analytics-cross-field
Worktree: objectstack-issue-7598
Domain: domain:services
File surface: packages/services/service-analytics/src/ (comparand-shape.ts, read-scope-sql.ts, strategies/filter-normalizer.ts + their tests). ⛔ Not packages/spec (the corpus deliberately stays exported from @objectstack/driver-sql), ⛔ not driver-mongodb / driver-turso REMOTE (out of #5222's ruling scope, stays out of this one). Stop on breach.
Container & model: M (two independent compilers + a per-face declared-field-set question + an envelope correction), mode:subagent, model: opus — in-container despite M sizing: one-package verification radius, throughput per the maintainer's accelerate directive; flagged for auditability.
Serial constraints cleared: the triage-recorded blocked-in-fact is CLEARED at dispatch time — #5222 landed as f1544e2ac (PR #7582) after triage's 07:05Z read; CROSS_FIELD_CASES / cross-field-conformance-cases.ts verified present on origin/main just now. No other in-flight claim touches service-analytics; batch-mate #7545 is docs/qa/ only.
Required reading recorded by triage: #7604 (closed dup)'s three disposition questions, especially the envelope point — read-scope-sql currently refuses via READ_SCOPE_COMPILE_FAILED / 500; whichever way the port lands, an unsupported rule must stop presenting as a server error.
Measurement from the #7597 dev seat (PR #7672), left here so this card's executor inherits it rather than re-deriving it. Not a claim — #7597 deliberately did not touch service-analytics.
#7597 fixed the lowering sink: parseFilterAST now lowers an equality triple with a { $field } comparand to { field: { $eq: ref } } instead of the bare { field: { $field } }. That changes what arrives at this card's two emitters, in a way worth knowing before the implement-vs-refuse decision.
1. filter-normalizer does NOT uniformly refuse $field — it refuses the BARE form only. Measured on origin/main @ cc3555e:
The refusal comes from MONGO_TO_CUBE_OP's miss branch, which only sees $field when it sits in the OPERATOR position. In a comparand position the reference passes the gate: assertCompilableComparand checks only the LIKE family and $in/$nin members (its own docblock says so — "$eq and friends keep binding an object as JSON (toSqlBindValue), which is a separate account"), and toSqlBindValue then JSON-stringifies it. So the ORDERING spellings have been answering silently — a comparison against the literal text {"$field":"budget"}, zero rows, no error — since well before #5222.
That matters for this card's framing: the split #7598 closes is not only "analytics 400s where the drivers now answer rows". For five of the six scalar operators it is analytics silently answers the wrong rows, which is the worse half and is invisible to any test asserting only the emitted SQL.
2. #7597 moves the equality spelling into that same silent bucket. Post-#7597 the authored ['amount', '=', ref] reaches this emitter as { $eq: ref } and is accepted-then-JSON-bound like $gt, where it used to hit the bare-form refusal. Not a new defect class — one more spelling joining an existing hole — but it does mean the loud refusal you may have been counting on for = is gone as of that PR. #7597's dev seat left it alone on this card's precedent (per #7604's triage: #5222's seat declined to widen a security-relevant acceptance surface in a second subsystem as a side effect, and that was called the right call); refusing only $eq here would create a fresh asymmetry inside the face, which is exactly what #7597 was fixing elsewhere.
3. read-scope-sql is the same shape, with the envelope difference already recorded here. Its $eq/$gt arms bind through bind(params, val) with no field-reference check (assertCompilableMembers covers list members only), so a policy-compiled read scope carrying a reference binds the marker's JSON text too — a scope whose predicate silently does not constrain what it says it constrains.
4. Not reachable from the authoring sugar. Neither emitter can receive a $field from an array-format filter in a way #7597 changed beyond the above: compileScopedFilterToSql's two call sites (native-sql-strategy, objectql-strategy) are handed the RLS read SCOPE, which is compiled from policy/CEL and never passes through parseFilterAST.
Reference implementation for whichever way question 1 goes: driver-sql's applyCrossFieldComparison + CROSS_FIELD_COMPARISON_OPERATORS (the six scalar ops only), and the corpus at packages/drivers/driver-sql/src/cross-field-conformance-cases.ts — which as of PR #7672 also carries an AUTHORING arm (CROSS_FIELD_AUTHORED_CASES) entering through the lowering sink.
{
"issue": 7598,
"status": "needs_decision",
"branch": "claude/issue-7598-analytics-cross-field",
"pr": "https://github.com/objectstack-ai/objectstack/pull/7694",
"premise_still_valid": false,
"summary": "The card's premise is inverted, measured on origin/main @5823d593d: neither analytics compiler REFUSES a `$field` comparand — for the six scalar operators #5222 implemented, both BIND the reference object as the comparison's value. read-scope-sql emits `\"person\".\"amount\" > ?` with `[{\"$field\":\"budget\"}]` in the bind list; the `where` door's NativeSQLStrategy binds the JSON TEXT `{\"$field\":\"budget\"}`; the /analytics/sql echo binds the raw object; and the ObjectQL engine path passes the reference through to driver-sql, which since #5222 compiles it CORRECTLY. So there is no 400 anywhere — there is a silent wrong answer on three faces and a right one on the fourth, and on read-scope-sql the wrong one is an admin's RLS predicate. PR #7694 stops the bind at both doors (each in its existing envelope) and holds both faces to the shared #5222 corpus. It deliberately does NOT port the capability: all four #5222 rulings turn on an object's declared field set, its declared types and its tenant-isolation column, and StrategyContext exposes none of them — so the port needs a packages/spec surface change, which this card puts out of scope. Two contract-face questions below.",
"tests": "pnpm --filter @objectstack/service-analytics test => 'Test Files 72 passed (72) / Tests 1551 passed (1551)' (1444 before this change; the new suite adds 107). eslint --no-inline-config over all 5 changed files => no output. Gates: check-nul-bytes => 'OK (scanned 7091 text file(s) ... no raw ASCII control bytes)' + self-test 75 assertions; check-changeset-fixed => 'fixed group is in sync with 69 public workspace packages'; check-audit-scope => 'docs-accuracy-audit scope is in sync with content/docs/: 179 hand-written doc(s)'; check-adr-0087-registration --base origin/main => '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition ... [bang] not-required (no-migration-prescription)'; pnpm install --frozen-lockfile clean. CI CONVERGED on 1bfba2a9d: 26 check runs, 25 success + 1 skipped (Console Pin Gate), 0 failed — including ESLint (which carries the family gates), TypeScript Type Check, Test Core 1-3, Build Core/Docs, Temporal Conformance (live PG + MySQL), Validate Package Dependencies, Check Changeset, Dogfood 1-3. Labels read back after the bots settled: documentation, size/l, dependencies, tests, tooling — no skip-changeset needed (a real changeset ships). REVERSE VERIFICATION, direction predicted in writing first: with both assertNoFieldReferenceComparand call sites disabled => 87 failed / 47 passed, every failure reading 'expected the compiler to refuse this filter, but it returned ...' i.e. failing by RESOLVING, the predicted manner. Targeted re-read confirmed both halves: not one LIKE-family or $in/$nin corpus case went red (the new gate is narrow; those refusals come from the #5234 gates), and the two $between-endpoint cases went red on the `where` door ONLY — green on read-scope, where assertCompilableMembers already refused them, which is exactly why $between had to be named on the `where` door. comparand-shape-refusal.test.ts stayed green in full. Restored by inverse edit inside the worktree (no git stash); grep confirms zero markers remain and the suite is green again.",
"premise_evidence": {
"measured_on": "origin/main @5823d593d, via a probe driving both compilers and all three `where`-door emitters",
"table": {
"read-scope-sql `{amount:{$gt:{$field:'budget'}}}`": "COMPILED: sql `\"person\".\"amount\" > ?`, params `[{\"$field\":\"budget\"}]`",
"where -> NativeSQLStrategy": "COMPILED: `WHERE amount > $1`, params `[\"{\\\"$field\\\":\\\"budget\\\"}\"]` (typeof string)",
"where -> /analytics/sql echo": "COMPILED: `WHERE amount > $1`, params `[{\"$field\":\"budget\"}]` (typeof object)",
"where -> ObjectQL engine": "filter reaches executeAggregate INTACT as `{amount:{$gt:{$field:'budget'}}}` — driver-sql compiles it correctly since #5222",
"read scope -> ObjectQL engine": "reaches executeAggregate INTACT — never passes through read-scope-sql at all"
},
"two_card_claims_disproven": [
"'still refuse $field, so a CEL rule 400s on those faces' — nothing 400s. The failure mode is a silent wrong predicate, which is strictly worse and is why the fix shipped rather than waiting on the ruling.",
"'the convergence comment is now stale in one direction' — it is NOT stale. The pinned case is `{name:{$contains:{$field:'status'}}}`, a LIKE-family operator that #5222 left in its OWN refusal arm (a column-side LIKE pattern cannot be metacharacter-escaped portably). That pin still converges verbatim and is unchanged; only its comment gains the re-measurement. `isBindableComparand` is also still an exact mirror of driver-sql's twin — the divergence is that #5222 gave the driver a NEW path that runs AHEAD of that predicate, in a position neither analytics gate is ever asked about."
]
},
"declared_field_set_question_answered": {
"asked": "where does each compiler's declared-field set come from?",
"answer": "NEITHER face can obtain it, and the read-scope face has no schema knowledge at all.",
"measurements": [
"read-scope-sql: `compileScopedFilterToSql(filter, alias)` receives a FilterCondition and a table/alias STRING. Nothing else. Both call sites (native-sql-strategy.ts:308, objectql-strategy.ts:358) have `ctx`, but StrategyContext (packages/spec/src/contracts/analytics-service.ts:272) declares getCube / queryCapabilities / executeRawSql / executeAggregate / fallbackService / context / getReadScope / getAllowedRelationships / coerceTemporalFilterValue / coerceTemporalFilterColumn / isExternalObject — and NOT an object's declared field set, NOR its tenant-isolation column. grep over the whole package finds zero tenant-column knowledge outside prose.",
"the `where` door: the normalizer has no cube; the three emitters do. A cube's dimensions ARE a declared enumeration, but they are the wrong one — a Cube dimension carries `type` from the 5-value DimensionType enum ('string'|'number'|'boolean'|'time'|'geo'), not the Field type vocabulary #5222's comparison-class rule reads, carries no `multiple` flag (so the 'no scalar stored form' refusal cannot be made), and its `sql` may be an arbitrary expression. An RLS/tenant column is routinely not a declared dimension at all, so the cube set neither is nor approximates the object's declared field set.",
"driver-sql, for contrast, reads all four out of its own initObjects capture: declaredFieldsFor(table) (sql-driver.ts:9065) plus resolveTenantField(table) and each field's {type, multiple}."
],
"consequence": "Per the dispatch's own instruction for this case, both faces STOP rather than ship a weaker port: implementing $field without declared-only enumeration and without the tenant-column ban would violate two #5222 rulings, the second of which is the privilege-escalation surface. Closing the enumeration gap means adding a StrategyContext hook — i.e. packages/spec, explicitly out of this card's surface. What DID ship is the measured silent-bind fix, which needs no ruling and which every candidate architecture below agrees must happen."
},
"envelope_correction_not_made": "The dispatch's 必办 item (an unsupported rule must stop presenting as a server error; 4xx + locating info) is NOT implemented, because it contradicts the #5367 maintainer ruling of 2026-08-06 recorded verbatim in read-scope-sql.ts's header: 「⚠️ Deliberately NOT a 4xx of any flavour, including a 422. Option A on the decision card was READ_SCOPE_INVALID / 422 ('not your fault, not a crash'); it was rejected because no consumer reads a code on this path (so a new vocabulary had no measured pull), because a 4xx cannot be fixed by the client and therefore misreports the condition, and because 422 would have left the disclosure question to be re-decided message by message.」 Both of that ruling's arguments apply to THIS shape verbatim, not by analogy: the producer is an admin-authored sharing rule and its CEL lowering (compileCelToFilter is 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. Note #7604 itself only said the 500 'should probably' become a documented refusal; the mandate hardened a 'probably' into a requirement that reverses a maintainer ruling. Raised as open question 2 rather than decided by this seat.",
"open_questions": [
{
"question": "Where should cross-field `$field` be served on the analytics raw-SQL path — and does the enumeration it needs justify a new StrategyContext hook?",
"options": [
"A. Add StrategyContext hooks (getDeclaredFields(objectName) and getTenantColumn(objectName)) and implement the four #5222 rulings a second time inside service-analytics. Costs: a packages/spec contract addition, and the security rules duplicated in a second place where they can drift from the driver's copy.",
"B. NativeSQLStrategy.canHandle DECLINES a query whose `where` or read scope carries a `$field`, so it falls through to the ObjectQL/engine path — the mechanism it already uses for external objects (ADR-0062 D6). The driver then compiles it and enforces all four rulings with the metadata it owns. No new enumeration, no duplicated security rules, no spec change; the capability becomes uniform instead of face-dependent. Costs: the slower path serves those queries, and /analytics/sql (ObjectQLStrategy.generateSql, which calls compileScopedFilterToSql for its echo) still needs an answer — render nothing, or decline.",
"C. Keep the refusal PR #7694 ships as the permanent v1 — loud, converging with driver-sql's own refusal arm, capability available only via the engine path."
],
"recommendation": "B, with C as the shipped interim (which is what #7694 already is, so no work is wasted either way). On the three axes: REAL BUSINESS NEED — the producer is real and measured (compileCelToFilter emits this for a field-to-field CEL RLS rule) and B is the only option that makes one authored rule mean one thing on every analytics face, which is the pull; A builds a second enumeration surface for which no consumer has been measured. LONG-TERM SOUNDNESS — B reuses the existing decline-and-route mechanism and keeps the four security rulings enforced in exactly ONE place, next to the metadata that defines them; A creates two copies of a privilege-escalation guard, and a guard that exists twice is a guard that will disagree with itself. HARD TO GET WRONG — B means an AI-authored RLS rule either works or is routed to the face that can check it, never silently mis-answered; A adds two hooks that a host can decline to implement, and the fall-back when a hook is absent would have to be either 'refuse' (i.e. C anyway) or 'skip the check' (the failure this whole card is about). ⚠️ B's one real cost needs the maintainer's eye: declining on a filter SHAPE makes query routing depend on filter content, which is new behaviour for canHandle."
},
{
"question": "Should read-scope-sql keep answering READ_SCOPE_COMPILE_FAILED / 500 for a rule it cannot compile, given #7604's objection that a rule the drivers now execute should not present as a server error?",
"options": [
"A. Keep 500 + withheld message exactly as #5367 ruled (what #7694 ships).",
"B. Move to a 4xx as the dispatch mandates — reintroduces both defects #5367 closed: it bills a caller who cannot author the read scope, and it echoes a message naming the RLS policy's field names back to the tenant.",
"C. Keep 500 and the withhold, but give the unsupported-capability class its own ADR-0112 code (distinct from the compile-failure code), so operators and 5xx alerting can tell 'this platform does not support this rule here' from 'this compiler broke'. Addresses #7604's actual complaint — an opaque 500 indistinguishable from a crash — without touching attribution or disclosure."
],
"recommendation": "C, falling back to A. #7604's objection is legitimate and C is the smallest change that answers it; B answers it by undoing a ruling. This is a contract-face change to the ADR-0112 error-code vocabulary, so it is the maintainer's call, not a rider on the change that stops the silent bind. Note the pull for C is currently unmeasured — #5367 recorded that no consumer reads a code on this path — so A remains defensible on the startup-focus principle if nobody is actually alerting on it."
}
],
"out_of_scope_findings": [
"filed as #7693: `$icontains` is absent from comparand-shape.ts's TEXT_PATTERN_OPERATORS, so the #5234 comparand fence has NEVER covered it on the analytics `where` door — `{name:{$icontains:{foo:1}}}` still compiles to `LIKE '%[object Object]%'` there while read-scope-sql refuses the same comparand. One operator, two answers inside one package; same class as #7158 at a third face. Pinned as a RECORDED GAP block in the new suite so it shows in test output instead of being re-discovered; not fixed here because it is a second defect and would have ridden this card."
],
"deviations": [
"PR body opens `Part of #7598`, not `Fixes` — merging it must not close the card, because the port the card asks for is still in the decision box. The dispatch specified `Fixes`, on the assumption the port would land.",
"The mandated envelope correction was not made; see envelope_correction_not_made and open question 2.",
"The refusal at the `where` door NARROWS the ObjectQL engine path, which today passes the reference through to a driver that handles it properly. Taken deliberately and flagged as the part to re-open under option B: the pass-through is an accident of convertFilter forwarding an unrecognised comparand (nothing validates it, no test pinned it), and leaving it makes one authored `where` mean two things depending on the backend behind the cube. Pinned honestly in both directions — the suite asserts the `where` door now refuses AND that a read scope still reaches engine.aggregate intact.",
"@objectstack/driver-sql added as a devDependency of service-analytics so both faces can be driven against the SHARED corpus (CROSS_FIELD_CASES / CROSS_FIELD_REFUSALS). Test-only, no runtime edge; service-automation sets the precedent and Validate Package Dependencies is green. The card asked for the corpus to be exercised through both faces and it is — in the direction the measurement supports: EVERY case in BOTH arms is refused on both doors, which is the asymmetry this card records, stated as an executable tripwire that flips when the capability lands.",
"Corpus `messageIncludes` are deliberately NOT asserted — they pin driver-sql's wordings, and a driver message naming initObjects declarations would be a lie on this side. The envelope is asserted for every case; wordings are asserted separately per door, including an explicit test that the two arms are answered by DIFFERENT gates rather than one blanket refusal.",
"The test file's reverse-verification numbers were first written as an estimate BEFORE the measurement was taken, then corrected to the measured 87/47 with the prediction recorded in a separate scratch file first. Recording it rather than presenting the corrected figures as if they had always been right."
],
"changeset": {
"file": ".changeset/analytics-field-reference-comparand-refusal.md",
"bump": { "@objectstack/service-analytics": "minor" },
"justification": "A shape that compiled now refuses, on both doors — the same class as #5234, which took `minor` (with `!`) for exactly this on these same two doors. #6125 / #6387 took `patch`, but both were read-scope-door-only. Carries an ADR-0087 disposition of `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 engine path and both SQL drivers — so there is nothing for `objectstack migrate meta` to rewrite, and rewriting it would be wrong."
},
"docs_adr_touched": "NONE. No content/docs/releases/, no docs/adr/**. 'Flag docs affected by code changes' is green."
}
Activity
os-zhuang commentedon Aug 11, 2026
Triage:
pm:queue+ re-routeddomain:spec→domain:services.packages/services/service-analytics—src/strategies/filter-normalizer.ts, the read-scope lowering,src/__tests__/comparand-shape-refusal.test.ts(all verified present onorigin/main@744b8f5). The card explicitly keepspackages/specuntouched (the corpus stays exported from@objectstack/driver-sql, deliberately not promoted into spec), so the acceptance surface does not move — anchoring rule says the label follows the package the fix lands in:packages/services/*⇒domain:services. The filer-applieddomain:specwas a reasonable first guess but doesn't match the landing.$field编译为列对列比较(cross-field comparison push-down) #5222 rulings port directly" (same-table only, declared-only enumeration, tenant column forbidden, same comparison class); the one open point (where each compiler's declared-field set comes from) is an implementation question inside the port, not a contract fork.$field编译为列对列比较(cross-field comparison push-down) #5222's PR has not merged —CROSS_FIELD_CASEShas zero hits onorigin/main@744b8f5(counter-probe:isBindableComparandandFieldReferenceSchemagrep fine), so the reusable corpus this card builds on is not on main yet. Treat this as blocked-in-fact on the [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 PR landing; verify the export exists at dispatch time. Not markingpm:blockedbecause the machine half requires a body edit, which this seat avoids (sanitizer risk) — this comment is the record.FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596/[spec]parseFilterASTlowers['a', '=', { $field: 'b' }]to a bare field spec no backend reads as equality #7597 (spec faces) — this is the analytics-compiler port; no other open card namescomparand-shape.ts/filter-normalizer.ts.INVALID_FILTER/ 400).本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
$field— a CEL rule that now pushes down on the drivers still 400s through the analytics faces #7604 as a duplicate of this issueos-zhuang commentedon Aug 11, 2026
Cross-link from triage: #7604 (filed 07:22Z by the spec-lane PM, same residue —
read-scope-sql+filter-normalizerrefusing$field) is closed as duplicate of this card. Its three disposition questions are required reading at dispatch, especially the envelope point:read-scope-sqlrefuses viaREAD_SCOPE_COMPILE_FAILED/ 500 (notINVALID_FILTER/ 400), and whichever way this card lands, that path must stop presenting an unsupported rule as a server error.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-help commentedon Aug 11, 2026
Claim: PM loop, services lane, wave 2 round 4
Session:
session_015fkdTyGmMD5s8ZtEifvuGyBranch:
claude/issue-7598-analytics-cross-fieldWorktree:
objectstack-issue-7598Domain:
domain:servicesFile surface:
packages/services/service-analytics/src/(comparand-shape.ts,read-scope-sql.ts,strategies/filter-normalizer.ts+ their tests). ⛔ Notpackages/spec(the corpus deliberately stays exported from@objectstack/driver-sql), ⛔ notdriver-mongodb/driver-tursoREMOTE (out of #5222's ruling scope, stays out of this one). Stop on breach.Container & model: M (two independent compilers + a per-face declared-field-set question + an envelope correction),
mode:subagent,model: opus— in-container despite M sizing: one-package verification radius, throughput per the maintainer's accelerate directive; flagged for auditability.Serial constraints cleared: the triage-recorded blocked-in-fact is CLEARED at dispatch time — #5222 landed as
f1544e2ac(PR #7582) after triage's 07:05Z read;CROSS_FIELD_CASES/cross-field-conformance-cases.tsverified present onorigin/mainjust now. No other in-flight claim touchesservice-analytics; batch-mate #7545 isdocs/qa/only.Required reading recorded by triage: #7604 (closed dup)'s three disposition questions, especially the envelope point —
read-scope-sqlcurrently refuses viaREAD_SCOPE_COMPILE_FAILED/ 500; whichever way the port lands, an unsupported rule must stop presenting as a server error.Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Measurement from the #7597 dev seat (PR #7672), left here so this card's executor inherits it rather than re-deriving it. Not a claim — #7597 deliberately did not touch
service-analytics.#7597 fixed the lowering sink:
parseFilterASTnow lowers an equality triple with a{ $field }comparand to{ field: { $eq: ref } }instead of the bare{ field: { $field } }. That changes what arrives at this card's two emitters, in a way worth knowing before the implement-vs-refuse decision.1.
filter-normalizerdoes NOT uniformly refuse$field— it refuses the BARE form only. Measured onorigin/main@cc3555e:The refusal comes from
MONGO_TO_CUBE_OP's miss branch, which only sees$fieldwhen it sits in the OPERATOR position. In a comparand position the reference passes the gate:assertCompilableComparandchecks only the LIKE family and$in/$ninmembers (its own docblock says so — "$eqand friends keep binding an object as JSON (toSqlBindValue), which is a separate account"), andtoSqlBindValuethen JSON-stringifies it. So the ORDERING spellings have been answering silently — a comparison against the literal text{"$field":"budget"}, zero rows, no error — since well before #5222.That matters for this card's framing: the split #7598 closes is not only "analytics 400s where the drivers now answer rows". For five of the six scalar operators it is analytics silently answers the wrong rows, which is the worse half and is invisible to any test asserting only the emitted SQL.
2. #7597 moves the equality spelling into that same silent bucket. Post-#7597 the authored
['amount', '=', ref]reaches this emitter as{ $eq: ref }and is accepted-then-JSON-bound like$gt, where it used to hit the bare-form refusal. Not a new defect class — one more spelling joining an existing hole — but it does mean the loud refusal you may have been counting on for=is gone as of that PR. #7597's dev seat left it alone on this card's precedent (per #7604's triage: #5222's seat declined to widen a security-relevant acceptance surface in a second subsystem as a side effect, and that was called the right call); refusing only$eqhere would create a fresh asymmetry inside the face, which is exactly what #7597 was fixing elsewhere.3.
read-scope-sqlis the same shape, with the envelope difference already recorded here. Its$eq/$gtarms bind throughbind(params, val)with no field-reference check (assertCompilableMemberscovers list members only), so a policy-compiled read scope carrying a reference binds the marker's JSON text too — a scope whose predicate silently does not constrain what it says it constrains.4. Not reachable from the authoring sugar. Neither emitter can receive a
$fieldfrom an array-format filter in a way #7597 changed beyond the above:compileScopedFilterToSql's two call sites (native-sql-strategy,objectql-strategy) are handed the RLS read SCOPE, which is compiled from policy/CEL and never passes throughparseFilterAST.Reference implementation for whichever way question 1 goes:
driver-sql'sapplyCrossFieldComparison+CROSS_FIELD_COMPARISON_OPERATORS(the six scalar ops only), and the corpus atpackages/drivers/driver-sql/src/cross-field-conformance-cases.ts— which as of PR #7672 also carries an AUTHORING arm (CROSS_FIELD_AUTHORED_CASES) entering through the lowering sink.Generated by Claude Code
chore(changeset): answer the ADR-0087 disposition for the #7598 refus…
os-help commentedon Aug 11, 2026
OS-DEV-REPORT
{ "issue": 7598, "status": "needs_decision", "branch": "claude/issue-7598-analytics-cross-field", "pr": "https://github.com/objectstack-ai/objectstack/pull/7694", "premise_still_valid": false, "summary": "The card's premise is inverted, measured on origin/main @5823d593d: neither analytics compiler REFUSES a `$field` comparand — for the six scalar operators #5222 implemented, both BIND the reference object as the comparison's value. read-scope-sql emits `\"person\".\"amount\" > ?` with `[{\"$field\":\"budget\"}]` in the bind list; the `where` door's NativeSQLStrategy binds the JSON TEXT `{\"$field\":\"budget\"}`; the /analytics/sql echo binds the raw object; and the ObjectQL engine path passes the reference through to driver-sql, which since #5222 compiles it CORRECTLY. So there is no 400 anywhere — there is a silent wrong answer on three faces and a right one on the fourth, and on read-scope-sql the wrong one is an admin's RLS predicate. PR #7694 stops the bind at both doors (each in its existing envelope) and holds both faces to the shared #5222 corpus. It deliberately does NOT port the capability: all four #5222 rulings turn on an object's declared field set, its declared types and its tenant-isolation column, and StrategyContext exposes none of them — so the port needs a packages/spec surface change, which this card puts out of scope. Two contract-face questions below.", "tests": "pnpm --filter @objectstack/service-analytics test => 'Test Files 72 passed (72) / Tests 1551 passed (1551)' (1444 before this change; the new suite adds 107). eslint --no-inline-config over all 5 changed files => no output. Gates: check-nul-bytes => 'OK (scanned 7091 text file(s) ... no raw ASCII control bytes)' + self-test 75 assertions; check-changeset-fixed => 'fixed group is in sync with 69 public workspace packages'; check-audit-scope => 'docs-accuracy-audit scope is in sync with content/docs/: 179 hand-written doc(s)'; check-adr-0087-registration --base origin/main => '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition ... [bang] not-required (no-migration-prescription)'; pnpm install --frozen-lockfile clean. CI CONVERGED on 1bfba2a9d: 26 check runs, 25 success + 1 skipped (Console Pin Gate), 0 failed — including ESLint (which carries the family gates), TypeScript Type Check, Test Core 1-3, Build Core/Docs, Temporal Conformance (live PG + MySQL), Validate Package Dependencies, Check Changeset, Dogfood 1-3. Labels read back after the bots settled: documentation, size/l, dependencies, tests, tooling — no skip-changeset needed (a real changeset ships). REVERSE VERIFICATION, direction predicted in writing first: with both assertNoFieldReferenceComparand call sites disabled => 87 failed / 47 passed, every failure reading 'expected the compiler to refuse this filter, but it returned ...' i.e. failing by RESOLVING, the predicted manner. Targeted re-read confirmed both halves: not one LIKE-family or $in/$nin corpus case went red (the new gate is narrow; those refusals come from the #5234 gates), and the two $between-endpoint cases went red on the `where` door ONLY — green on read-scope, where assertCompilableMembers already refused them, which is exactly why $between had to be named on the `where` door. comparand-shape-refusal.test.ts stayed green in full. Restored by inverse edit inside the worktree (no git stash); grep confirms zero markers remain and the suite is green again.", "premise_evidence": { "measured_on": "origin/main @5823d593d, via a probe driving both compilers and all three `where`-door emitters", "table": { "read-scope-sql `{amount:{$gt:{$field:'budget'}}}`": "COMPILED: sql `\"person\".\"amount\" > ?`, params `[{\"$field\":\"budget\"}]`", "where -> NativeSQLStrategy": "COMPILED: `WHERE amount > $1`, params `[\"{\\\"$field\\\":\\\"budget\\\"}\"]` (typeof string)", "where -> /analytics/sql echo": "COMPILED: `WHERE amount > $1`, params `[{\"$field\":\"budget\"}]` (typeof object)", "where -> ObjectQL engine": "filter reaches executeAggregate INTACT as `{amount:{$gt:{$field:'budget'}}}` — driver-sql compiles it correctly since #5222", "read scope -> ObjectQL engine": "reaches executeAggregate INTACT — never passes through read-scope-sql at all" }, "two_card_claims_disproven": [ "'still refuse $field, so a CEL rule 400s on those faces' — nothing 400s. The failure mode is a silent wrong predicate, which is strictly worse and is why the fix shipped rather than waiting on the ruling.", "'the convergence comment is now stale in one direction' — it is NOT stale. The pinned case is `{name:{$contains:{$field:'status'}}}`, a LIKE-family operator that #5222 left in its OWN refusal arm (a column-side LIKE pattern cannot be metacharacter-escaped portably). That pin still converges verbatim and is unchanged; only its comment gains the re-measurement. `isBindableComparand` is also still an exact mirror of driver-sql's twin — the divergence is that #5222 gave the driver a NEW path that runs AHEAD of that predicate, in a position neither analytics gate is ever asked about." ] }, "declared_field_set_question_answered": { "asked": "where does each compiler's declared-field set come from?", "answer": "NEITHER face can obtain it, and the read-scope face has no schema knowledge at all.", "measurements": [ "read-scope-sql: `compileScopedFilterToSql(filter, alias)` receives a FilterCondition and a table/alias STRING. Nothing else. Both call sites (native-sql-strategy.ts:308, objectql-strategy.ts:358) have `ctx`, but StrategyContext (packages/spec/src/contracts/analytics-service.ts:272) declares getCube / queryCapabilities / executeRawSql / executeAggregate / fallbackService / context / getReadScope / getAllowedRelationships / coerceTemporalFilterValue / coerceTemporalFilterColumn / isExternalObject — and NOT an object's declared field set, NOR its tenant-isolation column. grep over the whole package finds zero tenant-column knowledge outside prose.", "the `where` door: the normalizer has no cube; the three emitters do. A cube's dimensions ARE a declared enumeration, but they are the wrong one — a Cube dimension carries `type` from the 5-value DimensionType enum ('string'|'number'|'boolean'|'time'|'geo'), not the Field type vocabulary #5222's comparison-class rule reads, carries no `multiple` flag (so the 'no scalar stored form' refusal cannot be made), and its `sql` may be an arbitrary expression. An RLS/tenant column is routinely not a declared dimension at all, so the cube set neither is nor approximates the object's declared field set.", "driver-sql, for contrast, reads all four out of its own initObjects capture: declaredFieldsFor(table) (sql-driver.ts:9065) plus resolveTenantField(table) and each field's {type, multiple}." ], "consequence": "Per the dispatch's own instruction for this case, both faces STOP rather than ship a weaker port: implementing $field without declared-only enumeration and without the tenant-column ban would violate two #5222 rulings, the second of which is the privilege-escalation surface. Closing the enumeration gap means adding a StrategyContext hook — i.e. packages/spec, explicitly out of this card's surface. What DID ship is the measured silent-bind fix, which needs no ruling and which every candidate architecture below agrees must happen." }, "envelope_correction_not_made": "The dispatch's 必办 item (an unsupported rule must stop presenting as a server error; 4xx + locating info) is NOT implemented, because it contradicts the #5367 maintainer ruling of 2026-08-06 recorded verbatim in read-scope-sql.ts's header: 「⚠️ Deliberately NOT a 4xx of any flavour, including a 422. Option A on the decision card was READ_SCOPE_INVALID / 422 ('not your fault, not a crash'); it was rejected because no consumer reads a code on this path (so a new vocabulary had no measured pull), because a 4xx cannot be fixed by the client and therefore misreports the condition, and because 422 would have left the disclosure question to be re-decided message by message.」 Both of that ruling's arguments apply to THIS shape verbatim, not by analogy: the producer is an admin-authored sharing rule and its CEL lowering (compileCelToFilter is 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. Note #7604 itself only said the 500 'should probably' become a documented refusal; the mandate hardened a 'probably' into a requirement that reverses a maintainer ruling. Raised as open question 2 rather than decided by this seat.", "open_questions": [ { "question": "Where should cross-field `$field` be served on the analytics raw-SQL path — and does the enumeration it needs justify a new StrategyContext hook?", "options": [ "A. Add StrategyContext hooks (getDeclaredFields(objectName) and getTenantColumn(objectName)) and implement the four #5222 rulings a second time inside service-analytics. Costs: a packages/spec contract addition, and the security rules duplicated in a second place where they can drift from the driver's copy.", "B. NativeSQLStrategy.canHandle DECLINES a query whose `where` or read scope carries a `$field`, so it falls through to the ObjectQL/engine path — the mechanism it already uses for external objects (ADR-0062 D6). The driver then compiles it and enforces all four rulings with the metadata it owns. No new enumeration, no duplicated security rules, no spec change; the capability becomes uniform instead of face-dependent. Costs: the slower path serves those queries, and /analytics/sql (ObjectQLStrategy.generateSql, which calls compileScopedFilterToSql for its echo) still needs an answer — render nothing, or decline.", "C. Keep the refusal PR #7694 ships as the permanent v1 — loud, converging with driver-sql's own refusal arm, capability available only via the engine path." ], "recommendation": "B, with C as the shipped interim (which is what #7694 already is, so no work is wasted either way). On the three axes: REAL BUSINESS NEED — the producer is real and measured (compileCelToFilter emits this for a field-to-field CEL RLS rule) and B is the only option that makes one authored rule mean one thing on every analytics face, which is the pull; A builds a second enumeration surface for which no consumer has been measured. LONG-TERM SOUNDNESS — B reuses the existing decline-and-route mechanism and keeps the four security rulings enforced in exactly ONE place, next to the metadata that defines them; A creates two copies of a privilege-escalation guard, and a guard that exists twice is a guard that will disagree with itself. HARD TO GET WRONG — B means an AI-authored RLS rule either works or is routed to the face that can check it, never silently mis-answered; A adds two hooks that a host can decline to implement, and the fall-back when a hook is absent would have to be either 'refuse' (i.e. C anyway) or 'skip the check' (the failure this whole card is about). ⚠️ B's one real cost needs the maintainer's eye: declining on a filter SHAPE makes query routing depend on filter content, which is new behaviour for canHandle." }, { "question": "Should read-scope-sql keep answering READ_SCOPE_COMPILE_FAILED / 500 for a rule it cannot compile, given #7604's objection that a rule the drivers now execute should not present as a server error?", "options": [ "A. Keep 500 + withheld message exactly as #5367 ruled (what #7694 ships).", "B. Move to a 4xx as the dispatch mandates — reintroduces both defects #5367 closed: it bills a caller who cannot author the read scope, and it echoes a message naming the RLS policy's field names back to the tenant.", "C. Keep 500 and the withhold, but give the unsupported-capability class its own ADR-0112 code (distinct from the compile-failure code), so operators and 5xx alerting can tell 'this platform does not support this rule here' from 'this compiler broke'. Addresses #7604's actual complaint — an opaque 500 indistinguishable from a crash — without touching attribution or disclosure." ], "recommendation": "C, falling back to A. #7604's objection is legitimate and C is the smallest change that answers it; B answers it by undoing a ruling. This is a contract-face change to the ADR-0112 error-code vocabulary, so it is the maintainer's call, not a rider on the change that stops the silent bind. Note the pull for C is currently unmeasured — #5367 recorded that no consumer reads a code on this path — so A remains defensible on the startup-focus principle if nobody is actually alerting on it." } ], "out_of_scope_findings": [ "filed as #7693: `$icontains` is absent from comparand-shape.ts's TEXT_PATTERN_OPERATORS, so the #5234 comparand fence has NEVER covered it on the analytics `where` door — `{name:{$icontains:{foo:1}}}` still compiles to `LIKE '%[object Object]%'` there while read-scope-sql refuses the same comparand. One operator, two answers inside one package; same class as #7158 at a third face. Pinned as a RECORDED GAP block in the new suite so it shows in test output instead of being re-discovered; not fixed here because it is a second defect and would have ridden this card." ], "deviations": [ "PR body opens `Part of #7598`, not `Fixes` — merging it must not close the card, because the port the card asks for is still in the decision box. The dispatch specified `Fixes`, on the assumption the port would land.", "The mandated envelope correction was not made; see envelope_correction_not_made and open question 2.", "The refusal at the `where` door NARROWS the ObjectQL engine path, which today passes the reference through to a driver that handles it properly. Taken deliberately and flagged as the part to re-open under option B: the pass-through is an accident of convertFilter forwarding an unrecognised comparand (nothing validates it, no test pinned it), and leaving it makes one authored `where` mean two things depending on the backend behind the cube. Pinned honestly in both directions — the suite asserts the `where` door now refuses AND that a read scope still reaches engine.aggregate intact.", "@objectstack/driver-sql added as a devDependency of service-analytics so both faces can be driven against the SHARED corpus (CROSS_FIELD_CASES / CROSS_FIELD_REFUSALS). Test-only, no runtime edge; service-automation sets the precedent and Validate Package Dependencies is green. The card asked for the corpus to be exercised through both faces and it is — in the direction the measurement supports: EVERY case in BOTH arms is refused on both doors, which is the asymmetry this card records, stated as an executable tripwire that flips when the capability lands.", "Corpus `messageIncludes` are deliberately NOT asserted — they pin driver-sql's wordings, and a driver message naming initObjects declarations would be a lie on this side. The envelope is asserted for every case; wordings are asserted separately per door, including an explicit test that the two arms are answered by DIFFERENT gates rather than one blanket refusal.", "The test file's reverse-verification numbers were first written as an estimate BEFORE the measurement was taken, then corrected to the measured 87/47 with the prediction recorded in a separate scratch file first. Recording it rather than presenting the corrected figures as if they had always been right." ], "changeset": { "file": ".changeset/analytics-field-reference-comparand-refusal.md", "bump": { "@objectstack/service-analytics": "minor" }, "justification": "A shape that compiled now refuses, on both doors — the same class as #5234, which took `minor` (with `!`) for exactly this on these same two doors. #6125 / #6387 took `patch`, but both were read-scope-door-only. Carries an ADR-0087 disposition of `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 engine path and both SQL drivers — so there is nothing for `objectstack migrate meta` to rewrite, and rewriting it would be wrong." }, "docs_adr_touched": "NONE. No content/docs/releases/, no docs/adr/**. 'Flag docs affected by code changes' is green." }Generated by Claude Code
Generated by Claude Code
22 remaining items
fix(service-analytics): gate the /analytics/query SQL echo on debug (o…
docs(objectql): name the include-relation boundary of a cross-field c…
docs(objectql): name the include-relation boundary of a cross-field c…
fix(service-analytics): the read-scope compiler refuses a list under …
fix(service-analytics): the ObjectQL execute face refuses an unrunnab…
fix(service-analytics)!: the analytics `where` door runs every arm of…
fix(service-analytics)!: the NativeSQL execute face and the /analytic…
fix(service-analytics): the ObjectQL face refuses a read scope carryi…
fix(service-analytics)!: the analytics where door runs the shared com…
fix(service-analytics)!: both analytics doors refuse the two $icontai…
fix(security)!: the RLS write check refuses a field-to-field comparis…
fix(service-analytics): both filter faces answer $empty by the field'…
fix(service-analytics): runtime strings state each decision in words …
fix(service-analytics): runtime strings state each decision in words …
fix(driver-sql,service-analytics): bucket the ISO week natively on SQ…