Skip to content

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

Closed

Description

@huangyiirene

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.

⚠️ This may deserve 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

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

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: graded needs-user-decision (deliberately left without domain:* — 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):

    • ① Platform long-term coherence — analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367's principle ("a caller is never billed a 4xx for, nor shown field names from, a document they cannot author") is either a property of the read-scope path as a whole or a property of one component's envelope. Today it is the latter, which means every future routing decision can re-open the hole. Stating it path-wide closes the class.
    • ② Measured business pull — narrow but real: the driver's INVALID_FILTER on 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 $field RLS rule by declining native SQL (#7598) #7916's routing.
    • ③ AI-agent error-resistance — the crux the card names is structural: the driver cannot distinguish "author's filter" from "read-scope filter". Any fix requiring the driver to guess provenance is the error-prone shape; tagging provenance at the boundary that merges the read scope in (so the withhold decision is mechanical) is the resistant one.
    • ④ Startup scope discipline — smallest coherent fix is at the seam (mark scope-originated predicates where they are merged; withhold on that mark), not a driver-wide error-envelope rework. Observation 2 (the unreachable-today capability configuration) needs no action and should not be bundled.

    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

  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    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:queue swapped in the same write, and the ruling picks the lane: domain:engine-core (the merge boundary lives in packages/objectql; drivers consume the mark).

    Implementation boundaries:


    Generated by Claude Code

  3. self-assigned this
    on Aug 12, 2026
  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop (engine-core seat #6019, 2026-08-12 shift)
    Session: session_01VGAePF7iGGUYUT8oX1cVgx
    Branch: claude/issue-7929-read-scope-refusal-attribution
    Worktree: objectstack-issue-7929 (mode:cloud — own clone)
    Domain: domain:engine-core
    Labels: pm:queue → pm:dispatched (atomic, with the assignee, in one write)
    File surface: ⚠️ unknown until step 1 measures it — the fence is below, and it is the point of this dispatch
    Container & model: M, mode:cloud, model: opus

    ⛔ 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-decision rather 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:

    On a read-scope-originated refusal, what does the driver's INVALID_FILTER 400 actually put in front of the tenant?

    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

    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"

    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

    1. If the remedy's true producer is in another lane — read-scope-sql / service-analytics (services) or driver-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 orphaned sys_metadata rows #7557 handled its persistence half.
    2. If the remedy would land in 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, rejectCredentialAggregation keys off the secret/password TYPES, so an internal-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.
    3. If the only coherent remedy requires re-deciding [spec] 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

    Repo rules

    Worktree-first before the first edit (git worktree add ../objectstack-issue-7929 -b claude/issue-7929-read-scope-refusal-attribution main). ⛔ Never git stash. ⛔ Never edit content/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

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Dispatched mode:cloud: session session_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

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Seat: domain:engine-core · session session_01TPF8u7dPnVQkPgEcP8yfpv · branch claude/issue-7929-read-scope-refusal-attribution (worktree objectstack-issue-7929)
    Outcome: STOPPED at fences 1 and 2, measurement in hand. No code landed, no PR.

    premise_still_valid: true

    The 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:

    POST /api/v1/analytics/query      real dispatcher-plugin route (createDispatcherPlugin)
      → real AnalyticsService         nativeSql AND objectqlAggregate both advertised,
                                      executeRawSql supplied — so NativeSQLStrategy wins
                                      unless it declines
      → NativeSQLStrategy.canHandle   DECLINES (read scope carries `{$field}`) — #7598 Q1=B
      → ObjectQLStrategy.withReadScope  ANDs the scope into the FilterCondition
      → real SqliteWasmDriver.aggregate  (driver-sqlite-wasm extends SqlDriver)
      → applyCrossFieldComparison     the #5222 validation gate refuses
    

    executeRawSql call 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 existing cross-field-engine-fallback.test.ts are 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 where AND 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:

    • the referent column name (organization_id, secret_policy_column, sharing_rule.manager_budget);
    • the target column the policy compares it against (stage, amount);
    • the operator the policy uses ($eq, $gt);
    • and, for the tenant-isolation case, a sentence naming which column is the tenant-isolation column of the object — a fact the platform otherwise keeps to itself.

    Case 3 is the sharpest: sharing_rule.manager_budget is pure admin-authored sharing-rule content, and it is returned verbatim in error.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. errorResponseBase withholds on declaresServerFault, which requires status >= 500. A declared 4xx passes through with its message intact, and that tiering is deliberate and already pinned: analytics-query-read-scope-withhold.test.ts carries 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/runtime analytics-query-read-scope-withhold.test.ts — the #5367 end-to-end pin:

     Test Files  1 passed (1)
          Tests  10 passed (10)
    

    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

    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"

    Verified rather than inherited, three ways:

    (a) The bodies are byte-identical. The same reference written by the author in the query's own where produces exactly the message of case 1 above, character for character. Two different producers, one indistinguishable answer.

    (b) The DriverQuery carries 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 FilterCondition in where. 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. DriverQuery is Omit<QueryAST, 'object'> and QueryAST has no provenance slot; DriverOptions carries transaction / timeout / skipCache / traceContext / tenantId / accessible_org_ids and nothing about filter origin.

    (c) The one thing that does cross is not usable for this. The analytics merge boundary hands executeAggregate an options object whose keys are groupBy, aggregations, filter, timezone, context, and context does 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-core on the basis that "the merge boundary lives in packages/objectql; drivers consume the mark". Measured, it does not. There are two merge boundaries and neither is packages/objectql:

    path who merges the read scope package lane
    analytics (this card's path) ObjectQLStrategy.withReadScope — { $and: [userFilter, scopeFilter] } packages/services/service-analytics services
    ordinary CRUD read security middleware — opCtx.ast.where = { $and: [opCtx.ast.where, ...extra] } (security-plugin.ts:2198) packages/plugins/plugin-security plugins

    And the consumer that must withhold — uncompilableFieldReferenceError / applyCrossFieldComparison — is packages/drivers/driver-sql, the drivers lane. packages/objectql is 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.ts

    Anything this lane could contribute is in packages/objectql/src/engine.ts:

    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 on QueryAST / DriverOptions. It cannot be smuggled as an undeclared key: rejectUnknownEngineOptions gates the options surface, and the repo's check:query-options-erasure ratchet governs that area. So the full remedy is spec + services + plugins + drivers, with objectql as 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:

    # surface table (2026-08-07 @ 48f98b0) measured now
    1 driver-sql applyFilterCondition :7083 :8762
    2 turso RemoteTransport buildWhereSQL :1526 :1668
    3 read-scope-sql compileScopedFilterToSql :259 :309
    4 filter-normalizer lowerAnalyticsWhere :1235 :1508
    5 formula matchesFilterCondition :73 :122
    ½ objectql having-filter :92 / :98 :220 / :233

    Which 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's where but 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/sql display echo, which is precisely why Q2 = A holds and why read-scope-sql was not touched here.

    Code

    None landed. No PR, no changeset. The measurement harnesses were scratch files, used and deleted; git status on the branch is clean and the branch is unmoved at a44d1b4. 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's analytics-query-read-scope-withhold.test.ts REST harness (real createDispatcherPlugin route) with service-analytics' cross-field-engine-fallback.test.ts service setup (real SqliteWasmDriver + CROSS_FIELD_OBJECT_FIELDS / CROSS_FIELD_ROWS, both capability flags on, executeAggregate bridged to driver.aggregate), and getReadScope returning the refused scope.

    Found and deliberately not fixed

    #7988 — filed, unassigned, no pm:queue, labels finding + 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 SqliteWasmDriver and a security-middleware-shaped read-filter injection:

    code:    INVALID_FILTER
    status:  400
    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" — …
    names the admin-authored column: true
    

    That path has gone straight to driver-sql since #5222 (2026-08-06), long before #7598's routing. It matters to this card's ruling because the implementation boundary written above — "extend the analytics-query-read-scope-withhold pin 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

  7. 23 remaining items

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions