Repository navigation
finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929
Description
Observation-class finding, filed unassigned with no pm:queue — for triage to grade and route. Raised by the dev on #7598 / PR #7916 as "one place the ruling is slightly awkward, stated rather than papered over", and graded by the services PM as not a blocker for that PR (reasoning below). Carries a second, smaller observation from the same report.
Observation 1 — the attribution seam
The 2026-08-12 maintainer ruling on #7598 (Q1 = B) has NativeSQLStrategy.canHandle decline a query whose where or read scope carries { $field: … }, so it routes to the ObjectQL/engine path and the driver enforces the four #5222 rulings with metadata it owns. That is working as ruled.
The consequence: a read scope carrying a reference the driver refuses — an undeclared column, or the tenant-isolation column — now surfaces as INVALID_FILTER / 400 from the driver, where before the ruling the analytics path answered READ_SCOPE_COMPILE_FAILED / 500 with a withheld message. The refusal moved to the driver along with the compilation.
Why this is not a violation of Q2 = A. Q2 = A governs read-scope-sql's own envelope, and PR #7916 leaves it untouched: same code, same status, same withheld message, and the "Deliberately NOT a 4xx of any flavour, including a 422" module-header paragraph is not rewritten. Verified on the diff. The 400 comes from a different component that the ruling deliberately routed to.
Why it is still worth recording. #5367's adjudication rests on an attribution argument — a caller must not be billed a 4xx for a document they cannot author, and policy field names must not be echoed to the tenant. On this one path, the ruling's routing produces exactly the 4xx that argument was written to prevent. Two independent decisions, each correct on its own terms, meeting at a seam neither anticipated.
Open question for whoever grades this: does the driver's INVALID_FILTER on a read-scope-originated filter need the same withhold treatment #5367 gave the analytics path — and if so, does that belong at the driver, or at the boundary that hands a read scope to it? Note the driver cannot currently distinguish "this filter came from an author's query" from "this filter came from a read scope the caller never saw", which is likely the crux.
needs-user-decision rather than a dispatch: it is a consequence of a ruling the maintainer made hours ago, and re-deciding it is not a dev-seat or PM-seat call. Filed as finding so triage grades it; the services seat is not pre-empting that.
Observation 2 — an unreachable-today configuration, named rather than hidden
The decline creates one configuration that could not previously occur: a host advertising nativeSql without an aggregate bridge now reaches resolveStrategy's dead end for a $field filter (native declines, and there is nothing to fall back to).
Measured on PR #7916: not reachable from AnalyticsServicePlugin, whose defaults derive both capability flags from the bridges it actually wired. Only a hand-overridden queryCapabilities can construct it. The PR adds a diagnostic for it anyway, with a narrowness control. Recorded here so the reasoning survives the session; no action is claimed to be needed today.
Explicitly NOT claimed
- No regression is asserted. PR feat(service-analytics): serve a
$fieldRLS rule by declining native SQL (#7598) #7916's 26 CI checks are green, including the#5367end-to-end pinanalytics-query-read-scope-withhold.test.tsinruntime(2096/2096). The withhold behaviour on the path analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 actually adjudicated is intact. - The ruling is not being re-litigated here. Q1 = B and Q2 = A stand. This card records a downstream consequence, not an objection.
Evidence
Measured 2026-08-12 by the dev on PR #7916 (packages/services/service-analytics, head 94c5580). Full measurement in the <!-- os-dev-report --> comment on #7598 (comment 5262307982); the PM's acceptance reasoning is in comment 5262400038.
Source
Split out of #7598 (PR #7916) — the dev's named residuals, deliberately not decided inside that card.
Refs: #5367 (the withhold adjudication), #5222 (the four cross-field rulings), #7596, #7597.
Activity
hotlong commentedon Aug 12, 2026
Triage: graded
needs-user-decision(deliberately left withoutdomain:*— the ruling itself picks the landing: driver-side withhold vs boundary-side, the #7497 leave-blank shape). The filer's instinct is confirmed: this is two maintainer rulings (#7598 Q1=B routing, #5367 withhold/attribution) meeting at a seam neither anticipated, and harmonizing rulings is not a PM or dev call. Not auto-adjudicable: it sits inside hours-old maintainer decisions and touches the information-withhold posture (security-adjacent).Four-lens block (①–④ per #7498):
INVALID_FILTERon a read-scope-originated reference can echo policy field names (including the tenant-isolation column) to a tenant who never wrote them. No customer report; the exposure is measured on PR feat(service-analytics): serve a$fieldRLS rule by declining native SQL (#7598) #7916's routing.Recommendation: rule that #5367's withhold follows the read scope wherever it is enforced — implemented as provenance-tagging at the boundary, with the driver's refusal unchanged for genuine author filters. Alternative (accept the 400 as the new honest behavior on this path and amend #5367's scope) is coherent but should be said explicitly if chosen.
Generated by Claude Code
hotlong commentedon Aug 12, 2026
Maintainer ruling — 2026-08-12
Ruled: provenance marker — #5367's withhold follows the read scope wherever it is enforced. Scope-originated predicates are tagged at the boundary that merges them into the query; a driver refusal on a tagged predicate gets the withhold treatment (no policy field names echoed to the caller, #5367 envelope semantics); refusals of genuine author-written filters are unchanged.
Provenance: maintainer, triage PM session chat (
session_01NKGoRBFZELVKivDkiAswdz), 2026-08-12, verbatim: 「7918 A,7917 ②,7900 收敛两扇门,7929 来源标记」. Recorded by the triage seat;needs-user-decision→pm:queueswapped in the same write, and the ruling picks the lane:domain:engine-core(the merge boundary lives inpackages/objectql; drivers consume the mark).Implementation boundaries:
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q1=B nor analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 is reopened — this ruling composes them: routing stands, withhold becomes a property of the read-scope path as a whole rather than of one component's envelope.service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 routing actually takes (analytics decline → ObjectQL/engine → driver). The cross-lane touch ondriver-sql's refusal envelope is expected — declare the full file surface in the claim; the driver-memory freeze does not apply (this is the shared refusal seam, not driver-memory investment).analytics-query-read-scope-withholdpin family to this path; (b) an author-written filter with the same bad reference still gets the descriptive 400. The distinction IS the deliverable.Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
⛔ Dispatched as a MEASUREMENT card. A fix is not the deliverable, and may not even be in this lane.
This card is explicit that it "may deserve
needs-user-decisionrather than a dispatch", and it is right that re-deciding the maintainer's #7598 Q1=B ruling is not a seat's call. So the dispatch does not send anyone to fix a ruling. It sends someone to answer the one question that must be answered before anybody — seat or maintainer — can decide anything:That is a measurement, not a decision, and every branch downstream of it changes:
⛔ Do not report which of these is true from reading the code. Reproduce the refusal and capture the actual response body a tenant receives.
The crux the card already identified — confirm or refute it
If that holds, the remedy cannot live in the driver, because the driver lacks the provenance to know when to withhold. It would belong at the boundary that hands a read scope to the driver — which is why triage routed this to engine-core. Verify that the driver genuinely lacks the provenance (does anything on the options/context object already carry it?) before accepting the premise; a refutation here is a more valuable result than a fix.
⛔ Fences — this card can stop in three legitimate places
read-scope-sql/service-analytics(services) ordriver-sql(drivers) — report with evidence and stop. ⛔ Do not patch from the consumer side. File-and-transfer, the way Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphanedsys_metadatarows #7557 handled its persistence half.packages/objectql/src/engine.ts— report and stop, do not write it. Two cards hold regions of that file right now (Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867 at:7922-8727/:9099/:10332-10379,rejectCredentialAggregationkeys off thesecret/passwordTYPES, so aninternal-flagged column is not covered — the same type-vs-flag gap #7728 just fixed on the read path #7922 at:9575-9608); this seat serializes the write rather than letting a third dev in blind.service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q1=B or analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 — report and stop. That is the maintainer's, and this seat will escalate it with your measurement attached. ⛔ Do not re-litigate either ruling; both stand.A stop at any of these three is a successful card, not a failed one, provided the measurement is in hand.
Standing context you must not re-derive
$fieldRLS rule by declining native SQL (#7598) #7916 leftread-scope-sql's own envelope untouched — same code, same status, same withheld message, header paragraph unrewritten. The 400 comes from a different component the ruling deliberately routed to. ⛔ Do not "fix"read-scope-sql.$fieldRLS rule by declining native SQL (#7598) #7916's 26 checks were green including the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 end-to-end pin (analytics-query-read-scope-withhold.test.ts, 2096/2096). The withhold behaviour on the path analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 actually adjudicated is intact.nativeSql-without-aggregate-bridge configuration is unreachable fromAnalyticsServicePluginand the PR already added a diagnostic. ⛔ Nothing is owed there; do not spend the card on it..claude/skills/pm-dispatch/references/compile-surfaces.mdand run its recheck string before asserting anything about which compilers see this filter — ⛔ do not copy that table from memory, it is PR-maintained.Repo rules
Worktree-first before the first edit (
git worktree add ../objectstack-issue-7929 -b claude/issue-7929-read-scope-refusal-attribution main). ⛔ Nevergit stash. ⛔ Never editcontent/docs/releases/,docs/adr/**,.claude/skills/**. If code lands: changeset, reverse verification with the direction predicted before running, and both repo ratchets are whole-repo and run only inside the ESLint and TypeScript Type Check CI jobs — ⛔ neither baseline may be raised.Report as an
<!-- os-dev-report -->comment here, whether or not a PR exists.Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
Dispatched
mode:cloud: sessionsession_01TPF8u7dPnVQkPgEcP8yfpv(created 2026-08-12T09:12Z,model: claude-opus-5, outcome branch = the claim branch). Report expected as an<!-- os-dev-report -->comment here — ⛔ required whether or not a PR exists, since a stop with the measurement in hand is the expected shape of this card.The three stop conditions and the "capture the actual response body, ⛔ do not answer from the code" instruction went into the brief verbatim, along with the ⛔ against touching
read-scope-sql(Q2 = A is not in question) and the note that Observation 2 is closed so the card is not spent on it.Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
premise_still_valid: trueThe 400 names the policy field. #5367's adjudicated invariant is live-broken on this path: the caller is billed a 4xx for a document they cannot author and is told the policy field name the administrator wrote. This is the first of the two branches the dispatch named — the restore-invariant condition, not the smaller envelope-consistency one.
How it was measured — not read off the code
Real stack, driven through the real HTTP route:
executeRawSqlcall count was 0 on every refusal case, which is the measurement that the decline actually happened rather than native SQL quietly serving it; the literal-comparand controls in the existingcross-field-engine-fallback.test.tsare what stop that from being vacuous.Scopes were supplied through
getReadScope— the seam the administrator's policy arrives through in production (security.getReadFilter←compileCelToFilter, which emits{ $field: path }for a field-to-field CEL rule). The caller never wrote any of them.The captured 400 response bodies, verbatim
1. Read scope references the tenant-isolation column —
{"stage":{"$eq":{"$field":"organization_id"}}}{ "success": false, "error": { "code": "INVALID_FILTER", "message": "Operator \"$eq\" on field \"stage\" compares against another field ({ \"$field\": \"organization_id\" }), which cannot be compiled here: \"organization_id\" is the tenant-isolation column of \"cross_field_deal\", which must not appear on either side of a cross-field comparison. Cross-field comparison on SQL push-down supports same-table columns the object declares, compared as the same type class, excluding the tenant-isolation column.", "httpStatus": 400 } }2. Read scope references an undeclared policy column —
{"amount":{"$gt":{"$field":"secret_policy_column"}}}{ "success": false, "error": { "code": "INVALID_FILTER", "message": "Operator \"$gt\" on field \"amount\" compares against another field ({ \"$field\": \"secret_policy_column\" }), which cannot be compiled here: \"secret_policy_column\" is not a declared field of \"cross_field_deal\" — only declared fields can be referenced. Cross-field comparison on SQL push-down supports same-table columns the object declares, compared as the same type class, excluding the tenant-isolation column.", "httpStatus": 400 } }3. Read scope references a dotted policy path —
{"amount":{"$gt":{"$field":"sharing_rule.manager_budget"}}}{ "success": false, "error": { "code": "INVALID_FILTER", "message": "Operator \"$gt\" on field \"amount\" compares against another field ({ \"$field\": \"sharing_rule.manager_budget\" }), which cannot be compiled here: \"sharing_rule.manager_budget\" is a dotted path, and SQL push-down compiles same-table column references only (no relation traversal, no alias-qualified columns). Cross-field comparison on SQL push-down supports same-table columns the object declares, compared as the same type class, excluding the tenant-isolation column.", "httpStatus": 400 } }4. Read scope compares across type classes —
{"stage":{"$gt":{"$field":"amount"}}}{ "success": false, "error": { "code": "INVALID_FILTER", "message": "Operator \"$gt\" on field \"stage\" compares against another field ({ \"$field\": \"amount\" }), which cannot be compiled here: \"stage\" is stored as text but \"amount\" as numeric, and a cross-class comparison answers differently in SQL (storage-class ordering) than in memory (JS coercion) — compare same-class columns. Cross-field comparison on SQL push-down supports same-table columns the object declares, compared as the same type class, excluding the tenant-isolation column.", "httpStatus": 400 } }5. Composed — author
whereAND a refused read scope (where: {"stage":"won"}, scope as case 2): status 400, body identical to case 2. The merged predicate the driver received:{ "where": { "$and": [ { "stage": "won" }, { "amount": { "$gt": { "$field": "secret_policy_column" } } } ] } }What it discloses
Every case echoes the whole reference the administrator wrote — both operands. Concretely, to a tenant who authored none of it:
organization_id,secret_policy_column,sharing_rule.manager_budget);stage,amount);$eq,$gt);Case 3 is the sharpest:
sharing_rule.manager_budgetis pure admin-authored sharing-rule content, and it is returned verbatim inerror.message.The status class did not merely move from 500-withheld to 400-withheld. It moved from 500-withheld to 400-disclosed, which is the branch the dispatch said was the restore-invariant condition.
Why the boundary cannot catch it
Not a boundary defect — the boundary is behaving exactly as ruled.
errorResponseBasewithholds ondeclaresServerFault, which requiresstatus >= 500. A declared 4xx passes through with its message intact, and that tiering is deliberate and already pinned:analytics-query-read-scope-withhold.test.tscarries the case "a DECLARED 4xx is untouched — the withhold is 5xx-only", and #5667 deliberately kept undeclared 5xx legible. Widening the withhold to 4xx would delete both decisions and is not a repair anyone should reach for.No regression — confirmed, not assumed
packages/runtimeanalytics-query-read-scope-withhold.test.ts— the #5367 end-to-end pin:The withhold behaviour on the path #5367 actually adjudicated is intact, as the dispatch said. What is broken is the sibling path #7598's routing created.
The provenance crux — CONFIRMED
Verified rather than inherited, three ways:
(a) The bodies are byte-identical. The same reference written by the author in the query's own
whereproduces exactly the message of case 1 above, character for character. Two different producers, one indistinguishable answer.(b) The
DriverQuerycarries no mark. Captured at the driver call, read-scope case 1:{ "objectName": "cross_field_deal", "query": { "where": { "stage": { "$eq": { "$field": "organization_id" } } }, "groupBy": ["id"], "aggregations": [{ "field": "*", "function": "count", "alias": "n" }] } }The scope arrives as a bare
FilterConditioninwhere. In the composed case it becomes an$and— and neither arm carries anything saying which is the caller's and which is the policy's.DriverQueryisOmit<QueryAST, 'object'>andQueryASThas no provenance slot;DriverOptionscarriestransaction/timeout/skipCache/traceContext/tenantId/accessible_org_idsand nothing about filter origin.(c) The one thing that does cross is not usable for this. The analytics merge boundary hands
executeAggregatean options object whose keys aregroupBy, aggregations, filter, timezone, context, andcontextdoes carry principal facts:{ "positions": ["guest"], "permissions": [], "systemPermissions": [], "isSystem": false, "principalKind": "guest", "org_user_ids": [], "accessible_org_ids": [] }That says who is asking. It does not say which predicate subtree they did not write — which is the question the withhold decision turns on. So the refutation the dispatch hoped for does not hold: there is no existing provenance the driver could read, and a remedy that asks the driver to guess is the error-prone shape triage's lens ③ already named.
Where the remedy's producer actually lives — and why this seat stopped
Both fences fire, independently.
Fence 1 — the merge boundary is not in this lane
The 2026-08-12 ruling picked
domain:engine-coreon the basis that "the merge boundary lives inpackages/objectql; drivers consume the mark". Measured, it does not. There are two merge boundaries and neither ispackages/objectql:ObjectQLStrategy.withReadScope—{ $and: [userFilter, scopeFilter] }packages/services/service-analyticsopCtx.ast.where = { $and: [opCtx.ast.where, ...extra] }(security-plugin.ts:2198)packages/plugins/plugin-securityAnd the consumer that must withhold —
uncompilableFieldReferenceError/applyCrossFieldComparison— ispackages/drivers/driver-sql, the drivers lane.packages/objectqlis the carrier (opCtx.ast.where), not the producer of either mark.Per the dispatch's fence 1 — "if the remedy's true producer is in another lane … report with evidence and stop; ⛔ do not patch from the consumer side" — this seat did not write it.
Fence 2 — every engine-side piece lands in
engine.tsAnything this lane could contribute is in
packages/objectql/src/engine.ts:engine.aggregatebuilds theopCtx.astthat carries the filter —engine.ts:9601-9632, which overlapsrejectCredentialAggregationkeys off thesecret/passwordTYPES, so aninternal-flagged column is not covered — the same type-vs-flag gap #7728 just fixed on the read path #7922's held region:9575-9608(lines 9605-9607 are inside it);rejectUnknownEngineOptions(engine.ts:476) and added toENGINE_*_OPTION_KEYS(engine.ts:351,:464);find/countequivalents of the sameast.wherecarrier are in the same file.Per fence 2 — "report and stop, do not write it" — this seat did not.
A fourth lane the routing should account for
A mark that survives to the driver needs a declared home in
packages/spec— either on the filter shape (FilterConditionSchema) or onQueryAST/DriverOptions. It cannot be smuggled as an undeclared key:rejectUnknownEngineOptionsgates the options surface, and the repo'scheck:query-options-erasureratchet governs that area. So the full remedy is spec + services + plugins + drivers, withobjectqlas carrier — four lanes plus a held file, not one.Fence 3 was not hit: the ruling composes #7598 Q1 = B and #5367 rather than reopening either, and nothing measured requires re-deciding them. Both stand.
Compile surfaces — rechecked, not quoted from memory
Ran the reference's own recheck string (
grep -rn 'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql' packages --include=*.ts | grep -v node_modules). The table's line numbers are stale, as it warns they would be — current, measured on this branch:48f98b0)driver-sqlapplyFilterCondition:7083:8762buildWhereSQL:1526:1668read-scope-sqlcompileScopedFilterToSql:259:309filter-normalizerlowerAnalyticsWhere:1235:1508formulamatchesFilterCondition:73:122having-filter:92/:98:220/:233Which surfaces actually see this filter, from the captured stack: surface 1 only on the served path (
applyFilterCondition:8870→applyCrossFieldComparison:9160→uncompilableFieldReferenceError:1138→unsupportedFilterError:554). Surface 4 sees the caller'swherebut not the scope. Surface 3 (read-scope-sql) is not on the served path at all — after the ruling its remaining caller is the/analytics/sqldisplay echo, which is precisely why Q2 = A holds and whyread-scope-sqlwas not touched here.Code
None landed. No PR, no changeset. The measurement harnesses were scratch files, used and deleted;
git statuson the branch is clean and the branch is unmoved ata44d1b4. No gates were run because nothing changed — and per the dispatch, no gate is reported green that was not watched go green.The harness is cheap to rebuild for whoever implements: it is
packages/runtime'sanalytics-query-read-scope-withhold.test.tsREST harness (realcreateDispatcherPluginroute) withservice-analytics'cross-field-engine-fallback.test.tsservice setup (realSqliteWasmDriver+CROSS_FIELD_OBJECT_FIELDS/CROSS_FIELD_ROWS, both capability flags on,executeAggregatebridged todriver.aggregate), andgetReadScopereturning the refused scope.Found and deliberately not fixed
#7988 — filed, unassigned, no
pm:queue, labelsfinding+security; read back after filing.The disclosure is not analytics-only, and does not need #7598 to occur. Measured on the ordinary CRUD read path with a real engine, a real
SqliteWasmDriverand a security-middleware-shaped read-filter injection:That path has gone straight to
driver-sqlsince #5222 (2026-08-06), long before #7598's routing. It matters to this card's ruling because the implementation boundary written above — "extend theanalytics-query-read-scope-withholdpin family to this path" — scopes the pins to the analytics face, and a fix scoped that way leaves the wider half open. Recorded there rather than acted on here; grading and routing it is triage's.Observation 2 on this card was treated as closed, per the dispatch — no time spent on it.
Generated by Claude Code
23 remaining items
fix(driver-sql,driver-turso): a cross-field `$field` refusal stops na…
feat(spec): read-scope filter-subtree provenance mark — restore the a…
fix(drivers): withhold the target field from a policy-authored INVALI…
docs(plugin-sharing): record the write-scope provenance omission as r…
test(driver-sql): pin what keeps a dotted key's bound literal off the…
fix(driver-sql): attribute an unresolvable distinct() column to the c…
fix(service-analytics): the ObjectQL execute face refuses an unrunnab…
fix(driver-sql, driver-turso): four filter-refusal doors read the pro…
fix(driver-sql, driver-turso): every filter-compile refusal goes thro…
fix(spec): os migrate meta guidance for the driver-*, kernel-* and sy…
fix(security)!: the RLS write check refuses a field-to-field comparis…
docs(deployment): validating-metadata.mdx names the driver refusal, n…
fix(driver-sql): the first boot of a new database no longer prints a …
fix(driver-sql): the five refusal log lines take the shared driver-fa…
fix(objectql): an in-process engine verb refuses an object name the r…