Repository navigation
security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with INVALID_FILTER / 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19975
分诊首次定级:
priority:p2·security·bug·domain:services·pm:blocked—— 分析服务的 ObjectQL 执行面把"跑不了的行级读范围"回成 400,错误信息里带出策略的字段名和比较值;修法的落点正被 #19975 的 PR 修改,排在它后面Path:
packages/services/service-analytics/src/strategies/objectql-strategy.ts(withReadScope→engine.aggregate())+ 共用的读范围入口packages/services/service-analytics/src/read-scope-sql.tsTriage: lands in
service-analytics⇒domain:services,security,bug,priority:p2,pm:blockedBlocked-by #19975; rationale: the #5367 ruling (re-affirmed by #7598 Q2 = A), recorded inread-scope-sql.ts's header, says a read-scope refusal is a withheld server fault — never a 4xx whose message hands the caller the RLS policy's field names and comparands — and the native / echo faces hold it, but the ObjectQL execute face lets the engine'sINVALID_FILTER/ 400 through with the policy field and the resolved comparand list in its message; p2 because it discloses policy internals (not rows), reachable today from authoredfield == current_user.MEMBERSHIP; blocked because the shared read-scope door this face composes through is the file PR #19994 (for #19975, dispatched) is editing, and that PR's own dev surfaced this finding.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T17:06Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),在main(bfa23a8f49)上核对,并用 git 取了 PR #19994 的 head 比对文件。本席核对
read-scope-sql.ts头注释"Every refusal here is a SERVER fault, and says so (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 …)":读范围的拒绝一律是READ_SCOPE_COMPILE_FAILED/ 500、不回显消息。与卡面引用的契约一致。objectql-strategy.ts:filter: this.withReadScope(objectName, filter, ctx)直接交给engine.aggregate();这条路径上没有把引擎的INVALID_FILTER改写成保留信息的 500。与卡面的读法一致。dev 的探针(INVALID_FILTER/ 400,消息含字段名和列表)本席未重跑;HTTP 回显那一段卡面自己也注明是读代码得出的。- PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994(read-scope-sql compiles
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975,pm:dispatched给 os-sales)改了read-scope-sql.ts(+71 行,在共用入口里新增assertNoListInEqualitySlot)和read-scope-refusal-envelope.test.ts。⇒ 本卡最自然的修法位置(所有 strategy 合并读范围都经过的那个入口)正在被在飞 PR 修改 ⇒ 同文件在飞兄弟。
定级说明
security、p2:泄露的是策略内部(字段名、比较值,例如解析后的成员集合),不是别人的数据行;但 analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 明确把它列为租户不得从错误体里读到的东西。pm:blocked而不是pm:queue:等 fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 合并后,列表进等值槽这一种形状可能已经在共用入口里被拒成 500;剩下要修的是一般规则(引擎对已合成读范围的任何 400 都不得原样回给调用方),这要在新的main上重新测量才知道范围。
执行要点
- read-scope-sql compiles
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 关闭后,在新main上先复现 dev 的探针,并补测 HTTP 那一段(经 REST analytics 信封)。 - ObjectQL 执行面对"跑不了的读范围"一律回
READ_SCOPE_COMPILE_FAILED/ 500、不回显引擎消息,与 native / echo 面一致;落点由实现者按测量选择。 ⚠️ 区分:调用方自己的where出错仍应是INVALID_FILTER/ 400(filter-normalizer.ts那条路);只有读范围那一半要保留信息。测试要同时钉住这两种。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19888
Unlock scan: the old blocker closed, and a new one is derived.
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T17:48Z- The previous blocker, read-scope-sql compiles
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975, closed 2026-09-24T16:50Z through PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 (e8f163fc3a). The read-scope compiler now refuses a list under$eqwith the withheld 500. - Re-derived on
origin/main.strategies/objectql-strategy.tsstill composes the scope throughwithReadScopeintoengine.aggregate(), with no envelope translation. The premise holds. - New serial constraint (the line above): semantic, not textual. [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as
IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 is in flight in the same package (strategies/filter-normalizer.ts,preview-evaluator.ts, frozen matrix). It makes the analytics caller-wheredoor refuse equality-slot lists withINVALID_FILTER/ 400. This card's fix must make the ObjectQL execute face tell a READ-SCOPE refusal (a withheld 500, per analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367) apart from a CALLER-whererefusal (400) for exactly those shapes. The file lists are disjoint, but the two changes meet in the ObjectQL face's error handling, so measuring after [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY asIN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 lands is the reliable order. It returns topm:queueon the unlock scan when [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY asIN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 closes.
Generated by Claude Code
- The previous blocker, read-scope-sql compiles
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-19995-objectql-read-scope-envelope
Worktree:objectstack-issue-19995
Domain:domain:services
Seat:domain:services#1
File surface:packages/services/service-analytics/src/strategies/objectql-strategy.ts(the read-scope merge boundarywithReadScopeand its callers on the execute and cross-object paths);packages/services/service-analytics/src/read-scope-sql.ts(only if the shared withheld envelope or a scope-shape check is reused from there); new test file(s) underpackages/services/service-analytics/src/;.changeset/19995-*.md. ⛔ Nopackages/spec, ⛔ nopackages/objectql(engine lane; PR #20012 holdsengine.ts). ⛔filter-normalizer.tsonly if the report explains why. Measurement first: reproduce the dev probe on the newmain, and measure the HTTP leg through the REST analytics envelope. (Stop on breach; explain in the report.)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate ⇒ default tier)
Clause-②: no
Thread-read: 5819231479
Serial constraints cleared:- Unlock re-derived at 2026-09-24T18:28Z: the blocker [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as
IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 closed through PR fix(service-analytics)!: the analyticswheredoor refuses a list in the equality slot instead of reading it asIN(#19888) #20008 (14add487b4onorigin/main, parent count 1). The caller-wheredoor now refuses equality-slot lists withINVALID_FILTER/ 400 before the engine. That is the semantic neighbour this card must stay distinct from. - The open-PR file intersection, taken over all 14 open PRs, hits nothing in
packages/services/service-analytics/. The only PR touchingpackages/objectql/src/engine.tsis fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (security: a by-id UPDATE's row-levelcheckis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989, this seat, draft), which is outside this card's surface. - service-analytics: object-form analytics
whereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010 (the object-form door's other arms, awaiting triage) is the same package, but it is not dispatched.
- Unlock re-derived at 2026-09-24T18:28Z: the blocker [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19995,
"status": "needs_decision",
"branch": "claude/issue-19995-objectql-read-scope-envelope",
"pr": "#20017",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF",
"premise_still_valid": true,
"summary": "Premise held on 14add48 and the HTTP leg was measured: both analytics doors relayed the engine's INVALID_FILTER / 400 with the policy field and comparand for a list-in-equality read scope, while NativeSQL and the echo answered READ_SCOPE_COMPILE_FAILED / 500. Draft PR #20017 adds assertReadScopeComparandsRunnable (read-scope-sql.ts, reusing the module-local envelope) and calls it on the scope ALONE at both engine-bound merges (withReadScope for the direct and cross-object base paths, resolveFkAttr for the referenced object): it runs the engine's own two shared faces (assertListComparandShapes, normalizeFilterComparandTypes from @objectstack/spec/data), so nine shape classes now answer the withheld 500 at both HTTP doors, the accept set is unchanged, and the caller's own where keeps its 400 (no blanket catch). The PR says 'Part of #19995' and the status is needs_decision because seven residue classes, refused by schema/context-reading engine doors and driver-sql, still relay policy content; fixing them soundly belongs to the engine/driver lanes (the refusal reading the #8220 mark) and re-opens the #5367 vs #7929-B envelope choice. The assignee field was already os-sales and was not touched; the newest Claim names this branch.",
"tests": "New file packages/services/service-analytics/src/tests/objectql-read-scope-refusal-envelope.test.ts (19 cases, real ObjectQL + SqliteWasmDriver). Measurement commit e490825 (test only, pre-fix): 'Tests 13 failed | 6 passed (19)', every refusal case 'expected INVALID_FILTER to be READ_SCOPE_COMPILE_FAILED', all 6 controls green. At 9a40317: new file 'Tests 19 passed (19)'; whole package 'Test Files 118 passed (118) / Tests 2560 passed (2560)'; 'pnpm --filter @objectstack/service-analytics typecheck' exit 0 and 'tsc --listFiles' includes the new test (count 1). Ablations via scripts/ablation-replace.mjs (anchor x1 -> x0, blob changed, trap restore; every restore 'blob == HEAD and git diff HEAD is empty'; strat HEAD blob b3d329cead, read-scope-sql HEAD blob 35184098df): A1 delete withReadScope call -> 12 failed | 7 passed; A2 delete resolveFkAttr call -> 1 failed | 18 passed (referenced-object case); A3 delete comparand-type face -> 3 failed (type rows); A4 delete list-shape face -> 10 failed; A5 judge the composed tree instead of the scope -> 1 failed ('expected READ_SCOPE_COMPILE_FAILED to be INVALID_FILTER' on the caller's engine-refused where control). No dist rebuild is involved: the subject is imported relatively from src. HTTP leg re-measured after the fix with service-analytics dist rebuilt (grep of the guard name in dist/index.js: 3): both doors answer 500 READ_SCOPE_COMPILE_FAILED with no policy content for the nine fixed classes; caller-where rows identical before and after; better-sqlite3 and wasm tables identical. Gates at 9a40317: dispatch-gates --commands derived 72 families (superset of the dispatch-time list), all exit 0 — check:dual-build-cjs-loads and check:type-check-debt first exit 3 PREREQUISITE NOT MET, re-run exit 0 after 'turbo run build --filter=./packages/* --filter=./packages//'; '--ran' reconciliation: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN; GITHUB_TOKEN node scripts/check-issue-citations.mjs exit 0 (10 judged, 10 resolve); eslint --no-inline-config --format json on the 4 touched TS files: 4 files, 0 errors, 0 warnings, and eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so untouched files' verdicts cannot move; pnpm lint is CI's. CI on the PR head: in_progress at report time (7 success, 2 skipped, 23 queued/in_progress, 0 failed).",
"mcp_calls": "0",
"api_writes": "2 — (1) pr_create through the fleet-write relay: repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/pulls (draft) — run 36048175870, success, PR #20017; (2) this os-dev-report comment via scripts/pm/post-stamped.mjs → relay → POST /repos//issues/19995/comments. Label writes: 0 (the dispatch named no label and skip-changeset does not apply: the diff publishes and carries a changeset). git push is not counted.",
"open_questions": [
{
"question": "Residue of this card: seven scope shape classes (a column the object does not have; retired/unknown operator; text operator on a non-text field; unreadable temporal comparand; combinator with a non-array operand; non-boolean $null/$exists; unknown filter placeholder) are still refused on the ObjectQL face by engine doors that read the object's schema or the request's context, or by driver-sql's own compile, and answer a 4xx whose message the HTTP doors relay with the policy's field (and for some, comparand). They cannot be judged from service-analytics without a second copy of those rules. Where and in which envelope should they be withheld?",
"options": [
"A1 — refusing layer reads the provenance mark, policy subtree = server fault: every engine filter-refusal door (lowerWhereFilterArray's declared-type and temporal doors, resolveWhereTokens) and driver-sql's unmarked refusals (missing column, operator vocabulary, combinator shape, flag domain) consult markFilterSubtreeProvenance; an offending node inside a 'policy' subtree answers READ_SCOPE_COMPILE_FAILED / 500 with the message withheld, 'author' keeps its full 400, unmarked keeps today's #7929-B withheld 400. Axes — business need: real, measured reachable shapes include schema drift (a policy naming a renamed/removed column) and CEL comparisons on date/number fields an admin can author; long-term: one mechanism (#8220 mark) at the one layer that sees the refusal, no second copy; anti-AI-error: structural (a new refusal door must pass provenance through one envelope helper), no consumer-side tolerance; startup scope: engine + driver-sql lanes (engine.ts is held by PR #20012), and it changes the envelope #7929 B ruled for policy-marked cross-field/bind refusals from withheld-400 to 500, so it needs the maintainer's word. Cost: medium (two lanes, conformance on driver-memory/mongodb for parity).",
"A2 — same doors read the mark, policy subtree = INVALID_FILTER / 400 with the message withheld (the #7929-B / #8220 shape everywhere). Axes — business need: same; long-term: one mechanism, and it keeps every driver/engine refusal a 4xx; anti-AI-error: same structure; startup scope: same lanes, no re-ruling of #7929 B. Cost: it leaves read-scope refusals as 4xx on the ObjectQL face, which #5367 ruled wrong (misattribution, hidden from 5xx alerting), and one scope keeps two envelopes across faces (500 native/echo and for the nine classes fixed here, withheld-400 for the residue).",
"C — analytics-side re-attribution: on an engine/driver 4xx for a scoped query, re-run the scope alone to see whose clause failed. Axes — long-term: a workaround (an extra query on every refused request, and an unscoped caller-only probe is a security smell); anti-AI-error: attribution by side effect, not declaration; driver compile refusals need execution to attribute. Not recommended.",
"D — declare the residue and stop at this PR. Axes — startup scope: cheapest; business need: leaves a measured p2 disclosure open for schema-drift policies; long-term: the two-envelope split stays. Not recommended for a security card."
],
"recommendation": "A1, because #5367 (re-affirmed #7598 Q2 = A) is the standing ruling for read-scope refusals and A1 is the only option that meets it on every arm with one mechanism at the layer that already carries the provenance mark. A2 is the fallback if the maintainer prefers not to re-rule #7929 B's envelope. Either way the card should stay open (this PR says 'Part of #19995'). Unmeasured and worth measuring first: whether the same driver-sql unmarked refusals also relay policy content on the ordinary CRUD face, where plugin-security merges the policy after the engine's doors run."
},
{
"question": "The dispatch asked for a closing-keyword first line on the PR body and for each refusal test to assert the thrown message does not contain the policy field. Both were changed; confirm.",
"options": [
"A — keep: 'Part of #19995' (the residue goes to the decision box, and a merge must not close the card), and tests assert the envelope plus the doors' shared withhold reads, with the thrown message keeping the detail for the operator's log (the module's #5367 design, pinned by read-scope-refusal-envelope.test.ts), and HTTP bodies measured by the probe",
"B — switch the PR to a closing keyword and file the residue as a new card"
],
"recommendation": "A, because the role file forbids a closing keyword on a card headed to the decision box, and asserting the thrown message is empty would contradict the module's own log-channel contract."
}
],
"out_of_scope_findings": [
"class: b · The NativeSQL read-scope compiler (and the /analytics/sql echo) compiles two scope shapes the shared comparand faces refuse: a plain-object comparand under $eq, and a null member in $in. Contract text: @objectstack/spec/data filter-comparand-type.ts 'A comparison value must be a string, number, bigint, boolean, null or Date' and the null-member carve-out ruled onto assertListComparandShapes ('Operator "$in" ... does not accept null as a list member'). Seam: spec:normalizeFilterComparandTypes / assertListComparandShapes → runtime:packages/services/service-analytics/src/read-scope-sql.ts compileField/compileOperator (NativeSQLStrategy.applyReadScope, ObjectQLStrategy.generateSql echo). Evidence: probe rows 'object comparand $eq' and '$in with null member' — NativeSQL execute OK and echo OK while the ObjectQL face refuses (400 before, 500 after this PR). Database-level outcome unmeasured (executeRawSql was stubbed). Dedupe words: 'read-scope compiler object comparand $eq binds', 'compileScopedFilterToSql null member $in', 'native sql read scope comparand type face', 'echo renders scope ObjectQL refuses'"
],
"files_changed": [
".changeset/19995-objectql-read-scope-envelope.md (new; '@objectstack/service-analytics': patch; Clause-②: no)",
"packages/services/service-analytics/src/read-scope-sql.ts (assertReadScopeComparandsRunnable + header section)",
"packages/services/service-analytics/src/strategies/objectql-strategy.ts (guard at withReadScope and resolveFkAttr)",
"packages/services/service-analytics/src/tests/objectql-read-scope-refusal-envelope.test.ts (new)",
"packages/services/service-analytics/package.json (devDependency @objectstack/objectql)",
"packages/services/service-analytics/vitest.config.ts (anchored alias @objectstack/objectql -> src)",
"pnpm-lock.yaml (+3 lines, the devDependency link)"
],
"gates": "72/72 derived families exit 0 at 9a40317 (two after a prerequisite build, see tests); check-issue-citations exit 0; narrowed eslint exit 0; package test exit 0; typecheck exit 0. Per-family exit codes recorded in the dev's scratch gates-results.txt.",
"deviations": [
"PR body opens with 'Part of #19995' instead of the dispatch's closing-keyword line: the residue goes to a decision, and the role file forbids a closing keyword on a card headed to the decision box (open_questions[1]).",
"Refusal tests assert the envelope and the doors' shared withhold reads (serverFaultProvenance 'declared', declaredRefusalMessage undefined); the thrown message deliberately keeps the detail for the operator's log per the module's #5367 design. The HTTP body 'no policy content' claim is measured end to end by the uncommitted probe on both doors and both drivers, not pinned in this package (packages/rest and packages/runtime are other lanes, and a runtime-level pin would be the natural home: runtime's analytics-query-read-scope-withhold.test.ts covers only the native path).",
"The committed test uses SqliteWasmDriver (a SqlDriver subclass, already a devDependency); better-sqlite3 is not resolvable from this package. The probe covered SqlDriver(better-sqlite3) too, and the answers were identical.",
"Surface beyond the expected landing: package.json devDependency, pnpm-lock.yaml and vitest.config.ts alias, needed for the dispatch's real-ObjectQL requirement without growing the check:test-source-alias ledger.",
"filter-normalizer.ts not touched. origin/main (fc6ddb8, one driver-sqlite-wasm commit, no shared path) not merged into the branch."
],
"measurement_table": [
"Probe (scratch, not committed): AnalyticsService (ObjectQL only) over a real ObjectQL + SqlDriver(better-sqlite3), and again over SqliteWasmDriver; both drivers answered identically in every row. HTTP legs through the real routes: runtime dispatcher POST /api/v1/analytics/query and rest POST /analytics/dataset/query. Before = base 14add48; after = 9a40317 with service-analytics dist rebuilt. 'policy content in body' = the synthetic policy field name or comparand appeared in the HTTP response body.",
"shape class | ObjectQL face before -> after | POST /analytics/query before -> after | POST /analytics/dataset/query before -> after | policy content in HTTP body before -> after | NativeSQL execute | echo /analytics/sql",
"list in implicit equality slot | INVALID_FILTER/400 -> READ_SCOPE_COMPILE_FAILED/500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"list under $eq | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"scalar under $in | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"scalar under $nin | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"one-bound $between | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"plain-object member in $in | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | RSCF/500 | RSCF/500",
"undefined comparand | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes (field) -> no | RSCF/500 | RSCF/500",
"plain-object comparand under $eq | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes -> no | OK (compiles) | OK (compiles)",
"null member in $in | 400 -> 500 | 400 -> 500 | 400 -> 500 | yes (field) -> no | OK (compiles) | OK (compiles)",
"list under $ne | INVALID_FILTER/400 (driver-sql, mark-aware, withheld) unchanged | 400 | 400 | no -> no | OK | OK",
"list under $gt | 400 withheld, unchanged | 400 | 400 | no -> no | OK | OK",
"nested relation value | 400 withheld, unchanged | 400 | 400 | no -> no | RSCF/500 | RSCF/500",
"cross-field $field to an undeclared column | 400 withheld, unchanged (#7929 B) | 400 | 400 | no -> no | declined (no strategy) | RSCF/500",
"column the object does not have | INVALID_FILTER/400 unchanged (driver-sql missing column) | 400 | 400 | yes -> yes (RESIDUE) | OK (stubbed executeRawSql) | OK",
"field name with a space (unsafe identifier) | 400 unchanged (driver-sql missing column) | 400 | 400 | partial (name up to the first space) -> same (RESIDUE) | RSCF/500 | RSCF/500",
"retired operator ($regex) | 400 unchanged (driver-sql) | 400 | 400 | yes (field) -> yes (RESIDUE) | RSCF/500 | RSCF/500",
"unknown operator | 400 unchanged (driver-sql) | 400 | 400 | yes (field) -> yes (RESIDUE) | RSCF/500 | RSCF/500",
"text operator on a number field | 400 unchanged (engine declared-type door) | 400 | 400 | yes (field) -> yes (RESIDUE) | OK | OK",
"temporal comparand the platform cannot read | 400 unchanged (engine temporal door) | 400 | 400 | yes (field + comparand) -> yes (RESIDUE) | OK | OK",
"combinator with a non-array operand | 400 unchanged (driver-sql) | 400 | 400 | yes -> yes (RESIDUE) | RSCF/500 | RSCF/500",
"non-boolean $null | 400 unchanged (driver-sql) | 400 | 400 | yes (field) -> yes (RESIDUE) | RSCF/500 | RSCF/500",
"non-boolean $exists | 400 unchanged (driver-sql) | 400 | 400 | yes (field) -> yes (RESIDUE) | RSCF/500 | RSCF/500",
"unknown filter placeholder | FILTER_TOKEN_UNKNOWN/400 unchanged (engine token resolver) | 400 | 400 | yes -> yes (RESIDUE) | OK | OK",
"emptied $nin (control, #13640 vacancy guard) | RSCF/500 unchanged | 500 | 500 | no -> no | RSCF/500 | RSCF/500",
"well-formed scope (control) | served, rows r1+r3 | 200 | 200 | - | OK | OK",
"composed: well-formed caller where + list-in-equality scope | INVALID_FILTER/400 (engine message names the scope field) -> READ_SCOPE_COMPILE_FAILED/500",
"caller where only, 13 shapes (implicit list, $eq list, $in scalar, $ne list, unknown field, text op on number, $nin scalar, one-bound $between, null member in $in, object member in $in, $eq object, $eq undefined, nested object) | identical before and after (400 families keep their messages; $in scalar / $nin scalar / $ne list are served by the caller door)",
"Hypothesis 1 (quote the scope reads): four reads. generateSql echo (objectql-strategy.ts:551 at base) compiles via compileScopedFilterToSql, already withheld, untouched; withReadScope (:638, guard :652) and resolveFkAttr (:1123, guard :1135) are the two engine-bound merges, both now guarded; NativeSQLStrategy.applyReadScope compiles, untouched. CONFIRMED.",
"Hypothesis 2 (engine comparand-shape refusal ignores the 'policy' mark): CONFIRMED. The scope reached the engine stamped 'policy' and the shape refusal's full text (field + comparand) was relayed. Only driver-sql's bind and cross-field refusals read the mark; driver-sql's missing-column, operator-vocabulary, combinator-shape and flag-domain refusals do not.",
"Hypothesis 3 (ObjectQL face never reaches the read-scope compiler, two envelopes): CONFIRMED. For 7 of the 9 fixed shape classes the NativeSQL and echo faces answered RSCF/500 while the ObjectQL face answered 400; the other two (plain-object comparand under $eq, null member in $in) compile on the NativeSQL and echo faces (out_of_scope_findings[0]).",
"RSCF = READ_SCOPE_COMPILE_FAILED."
],
"pr_body_new": "Part of #19995 — this PR closes the comparand half of the card: every scope shape the engine's two SHARED comparand faces refuse. The card stays open for the residue: scope shapes refused by engine doors that read the object's schema or the request's context, and bydriver-sql, still answer a relayed 400. Why that half is not done here, and the decision it needs, is under "Residue" below.\n\nClause-②: no\n\n## What changed\n\nThe ObjectQL execute face composed a row-level read scope into the engine'swherewithout judging it. A scope carrying a comparand the engine refuses came back as the engine'sINVALID_FILTER/ 400. The HTTP doors relay a 4xx's message, and that message named the policy's field and comparand. The NativeSQL face and the/analytics/sqlecho refuse the same scope asREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld (the #5367 ruling, re-affirmed as #7598 Q2 = A). So one scope got two envelopes, depending on which analytics face served it.\n\n-read-scope-sql.ts: newassertReadScopeComparandsRunnable(scope, objectName), next to the #13640 vacancy guard. It runs the two faces the engine itself runs on every object-formwhere(assertListComparandShapesandnormalizeFilterComparandTypesfrom@objectstack/spec/data) on the scope alone. It re-raises any refusal through the module's one envelope helper,readScopeCompileError, keeping the walk's sentence for the operator's log. It is exported from the file only, not from the package entry, so the published surface is unchanged.\n-objectql-strategy.ts: called at both engine-bound merges.\n -withReadScopecovers the direct path and the cross-object base aggregate.\n -resolveFkAttrcovers the referenced object's own scope.\n - It runs after the vacancy guard and before the'policy'mark.\n\nWhy at the merge boundary, on the scope alone. At that point the scope is still a distinguishable object. One line later it is$and-composed with the caller's filter, and then nothing downstream can tell whose clause a refusal came from.\n\nWhy the refusal set does not move. The engine's comparand-shape refusal does not read the'policy'provenance mark (measured below). Both faces are pure walks whose verdict on a subtree depends neither on the rest of the tree nor on any schema. So the scope alone answers exactly as the scope inside{ $and: [userFilter, scope] }does, and nothing the engine serves is refused. A{ $field }scope (served on this path under #7598 Q1 = B) is stepped around by both faces, as the engine steps around it.\n\n⛔ This is not a catch aroundexecuteAggregate. The caller's ownwherestill reaches the engine's doors for some shapes, and those keep theirINVALID_FILTER/ 400 with the message. This is pinned, and ablation A5 below shows that pin going red under misattribution.\n\n## Measurement: recorded before the fix\n\nMeasured on base14add487b4, using a scratch probe that is not committed:AnalyticsService(ObjectQL only) over a realObjectQL, run twice, once overSqlDriver(better-sqlite3) and once overSqliteWasmDriver. The two drivers gave the same answer in every row. The HTTP leg went through the real routes:@objectstack/runtime's dispatcherPOST /api/v1/analytics/queryand@objectstack/rest'sPOST /analytics/dataset/query. The read scopes are thegetReadScopecontract filled by hand. The same probe was re-run after the fix, against a rebuiltdist.\n\n| Read-scope shape class | Layer that answers on the ObjectQL face | Before (ObjectQL face and both HTTP doors) | Refusal text names policy content | After |\n|---|---|---|---|---|\n| List in the implicit equality slot | Engine, shared list-shape face |INVALID_FILTER/ 400, relayed | yes |READ_SCOPE_COMPILE_FAILED/ 500, withheld |\n| List under$eq| Engine, shared list-shape face | 400, relayed | yes | 500, withheld |\n| Scalar under$inor$nin| Engine, shared list-shape face | 400, relayed | yes | 500, withheld |\n| One-bound$between| Engine, shared list-shape face | 400, relayed | yes | 500, withheld |\n| Null member in$in| Engine, shared list-shape face | 400, relayed | yes | 500, withheld |\n| Plain-object member in$in; plain-object comparand under$eq;undefinedcomparand | Engine, shared comparand-type face | 400, relayed | yes | 500, withheld |\n| Emptied$nin(control) | Analytics vacancy guard (#13640) | 500, withheld | no (withheld) | unchanged |\n| List under$neor$gt; nested relation value; cross-field reference |driver-sql, which reads the mark | 400, message withheld | no | unchanged |\n| Column the object does not have |driver-sql, missing column | 400, relayed | yes | unchanged (residue) |\n| Retired or unknown operator |driver-sqloperator vocabulary | 400, relayed | yes (the field) | unchanged (residue) |\n| Text operator on a non-text field | Engine, declared-type door | 400, relayed | yes | unchanged (residue) |\n| Temporal comparand the platform cannot read | Engine, temporal door | 400, relayed | yes | unchanged (residue) |\n| Combinator with a non-array operand |driver-sql| 400, relayed | yes | unchanged (residue) |\n| Non-boolean$nullor$exists|driver-sql| 400, relayed | yes (the field) | unchanged (residue) |\n| Unknown filter placeholder | Engine token resolver |FILTER_TOKEN_UNKNOWN/ 400, relayed | yes | unchanged (residue) |\n| Well-formed scope (control) | — | 200, scoped rows | — | unchanged |\n\n- Hypothesis 2 (does the engine refusal read the mark?): confirmed. The scope reached the engine stamped'policy'bywithReadScope, and the comparand-shape refusal still returned its full text. Of the refusals in the table, onlydriver-sql's bind and cross-field refusals read the mark.\n- Hypothesis 3 (does the ObjectQL face reach the read-scope compiler?): confirmed. The ObjectQL execute face never reachescompileScopedFilterToSql. The NativeSQL face and the echo answeredREAD_SCOPE_COMPILE_FAILED/ 500 for every fixed class except two: a plain-object comparand under$eqand a null member in$in. That compiler compiles both of those (see Acceptance notes).\n- Scope reads quoted. The strategy has four:\n -generateSql's echo merge compiles throughcompileScopedFilterToSql, is already withheld, and is untouched.\n -withReadScopeandresolveFkAttrare the two engine-bound merges, and both are now guarded.\n -NativeSQLStrategy.applyReadScopecompiles and is untouched.\n\n## Tests\n\nThe new file ispackages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts, with 19 cases over a realObjectQLandSqliteWasmDriver.\n\n- Refusal cases. Each refused shape class asserts:\n -codeREAD_SCOPE_COMPILE_FAILEDandstatus500;\n - that the reads every analytics HTTP door takes before relaying prose (serverFaultProvenance(resolveThrownHttpError(err, 500))is'declared',declaredRefusalMessage(err)is undefined) mean the prose is withheld;\n - that the thrown message, which is the operator's log channel, still carries the detail.\n- Paths covered: the direct path, a well-formed callerwherebeside a refused scope, the cross-object base scope, and the referenced-object scope.\n- Controls:\n - a well-formed scope is served with its rows;\n - a cross-field scope is served;\n - an emptied$inbeside an own-rows grant is served;\n - a well-formed referenced-object scope buckets what it hides as(restricted);\n - the caller's ownwhererefused by the ENGINE staysINVALID_FILTER/ 400 with its message;\n - the caller's ownwherein the scope's refused shape stays 400 with its message.\n\nResults:\n\n- Measurement commite4908254bc(test only, before the fix):Tests 13 failed | 6 passed (19). Every refusal case receivedINVALID_FILTER, and every control was green.\n- At9a40317b15:\n - the new file givesTests 19 passed (19);\n - the whole package givesTest Files 118 passed (118),Tests 2560 passed (2560);\n -pnpm --filter @objectstack/service-analytics typecheckexits 0, andtsc --listFilesincludes the new test.\n\nAblations. Each leg ran throughscripts/ablation-replace.mjs(the anchor must hit exactly once, and the blob must change) with a trap. Each restore was proven: the blob equals HEAD, andgit diff HEADis empty.\n\n| Leg | Mutation | Result |\n|---|---|---|\n| A1 | Delete thewithReadScopecall | 12 failed, 7 passed. Every direct and cross-object-base refusal case receivedINVALID_FILTER. |\n| A2 | Delete theresolveFkAttrcall | 1 failed (the referenced-object case), 18 passed |\n| A3 | Delete the comparand-type face call | 3 failed (the three type-face rows) |\n| A4 | Delete the list-shape face call | 10 failed (the shape rows, the composed case, both cross-object cases) |\n| A5 | Judge the COMPOSED tree instead of the scope alone | 1 failed: the caller's engine-refusedwherereceivedREAD_SCOPE_COMPILE_FAILEDinstead ofINVALID_FILTER|\n\n## Gates\n\n- Derived gates. Derived at9a40317b15withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 72 families, a superset of the dispatch-time list. All 72 exited 0.\n -check:dual-build-cjs-loadsandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (exit 3). I built the./packages/*closure and re-ran both, and both exited 0.\n - The--ranreconciliation reported 72 derived, 72 run, 0 unrun.\n- Issue citations.GITHUB_TOKEN=… node scripts/check-issue-citations.mjsexited 0: 10 citations judged, 10 resolve.\n- Lint, narrowed to the four touched TypeScript files.eslint --no-inline-config --format jsonread 4 files and reported 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move a verdict on an untouched file.pnpm lintitself is CI's.\n\n## Acceptance notes\n\n-@objectstack/objectqlis a new devDependency, aliased to source in the package'svitest.config.tswith an anchored regex. This is the repaircheck:test-source-aliasprescribes; the ledger does not grow, and the gate is green. The refusal under test is the engine's own, so a stub bridge would only have tested the stub.\n- Envelope reuse. The guard lives inread-scope-sql.tsbecausereadScopeCompileErroris module-local by design. A guard in the strategy file would have needed a second spelling of the envelope.\n- A host with its ownexecuteAggregatebridge: the scope is now judged against the spec's shared comparand faces whichever bridge executes, the same posture as the vacancy guard. A custom bridge that used to tolerate one of these off-contract scope shapes now gets the withheld 500. The engine bridge refused all of them already.\n- Precedence. When the caller'swhereand the scope are both refused, and only the engine would refuse the caller's clause, the scope's 500 now answers first. A caller clause refused by the analyticswheredoor still answers first, with its 400.\n- NativeSQL face and echo: unchanged. They compile two shapes (a plain-object comparand under$eq, a null member in$in) that the shared faces and the ObjectQL face refuse. This was measured with a stubbedexecuteRawSql, so the database-level outcome is unmeasured. It is reported to the seat and not touched here.\n-origin/mainhas moved on by one commit (fc6ddb87a4, driver-sqlite-wasm text round-trip). It shares no path with this diff and is not merged.\n- Files not touched:filter-normalizer.ts,packages/spec,packages/objectql,packages/rest,packages/runtime.\n\n## Residue: why "Part of"\n\nThese classes still answer the engine's or the driver's 400 with the message relayed:\n\n- a column the object does not have;\n- a retired or unknown operator;\n- a text operator on a non-text field;\n- a temporal comparand the platform cannot read;\n- a combinator with a non-array operand;\n- a non-boolean$nullor$exists;\n- an unknown filter placeholder.\n\nThey are refused by engine doors that read the object's schema or the request's context, and bydriver-sql's own compile. This package cannot judge them without a second copy of those rules. The sound fix is at the refusing layer: the refusal reads the'policy'provenance mark (#8220), asdriver-sql's bind and cross-field refusals already do. That change is in the engine and driver lanes, and it opens an envelope question between the #5367 and #7929 rulings. It is handed back to the seat as a decision.\n"
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsReview — ACCEPT, partial (landing gated on CI) · PR #20017 · head
9a40317b15·domain:servicesseat · 2026-09-24T19:33ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main(fc6ddb87a4), ⛔ not against the report's own account.Checklist
- PR shape. Draft, targeting
main. The first line isPart of #19995, with no closing keyword (see the answer to open question 2).Clause-②: nostarts its own line. - Scope. 7 files, +501 / −2:
read-scope-sql.ts: the newassertReadScopeComparandsRunnableand a header section;objectql-strategy.ts: two call sites;- a new 19-case test file;
- a devDependency on
@objectstack/objectql, with its lockfile link and an anchored source alias invitest.config.ts; .changeset/19995-objectql-read-scope-envelope.md(@objectstack/service-analyticspatch).
Nopackages/spec, nopackages/objectqlsource, nofilter-normalizer.ts. No cycle:objectqldoes not depend onservice-analytics.
- Diff, read line by line.
- The guard runs the engine's two shared comparand faces on the scope alone, at both engine-bound merges (
withReadScope,resolveFkAttr). It runs after the vacancy guard and before the'policy'mark. Any throw is re-raised through the module's one envelope helper,readScopeCompileError. - The type face is copy-on-write, so its discarded return leaves the scope object untouched: the host's scope is not mutated.
- It is not a catch around
executeAggregate, so the caller'swherekeeps its 400.
- The guard runs the engine's two shared comparand faces on the scope alone, at both engine-bound merges (
- Tests. The measurement commit ran 13 red / 6 green before the fix. The new file is 19/19 green; the package is 2560 green.
- Ablations A1–A5 each go red, including A5 (judging the composed tree misattributes the caller's refusal), and every restore is blob-identical.
- The HTTP leg was measured on both doors and both drivers: the nine fixed classes go from 400 carrying policy content to 500 carrying none. Caller-
whererows are identical before and after.
- Changeset prose. I read it against the diff; every claim matches, including 「Unchanged: which scopes are served」 (same functions as the engine).
Open questions, answered:
- The residue (seven scope shape classes still answer a relayed 400 carrying policy content): to the maintainer. It cannot be fixed in this lane: the refusals are the engine's schema- and context-reading doors and
driver-sql's own compile. Choosing the envelope re-opens the standing rulings analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 (read scope = withheld 500) and 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 B (a policy-marked refusal = withheld 400). This card moves to the decision box once PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 lands, with the four-axis analysis. ⛔ Not decided by this seat. - "Part of" and the test assertions: A (accepted). A merge must not close a card headed to the decision box. The thrown message keeps its detail for the operator's log, as the module's analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 design says (pinned by
read-scope-refusal-envelope.test.ts). What is withheld is the HTTP body, measured on both doors.
Deviations accepted. The devDependency, lockfile and alias (the repair
check:test-source-aliasprescribes); the test driverSqliteWasmDriver(aSqlDriversubclass; better-sqlite3 was measured identical in the probe). The claim's file surface is revised to includepackage.json,vitest.config.tsandpnpm-lock.yaml(this line is the revision).Out-of-scope findings, one line each: the NativeSQL read-scope compiler and the echo compile two scope shapes the shared faces refuse (a plain-object comparand under
$eq, a null member in$in), so those two shapes still get two answers across the faces: filed next, class b.Landing. No Clause-② limb is hit: no
packages/spec/src, and the declaration isno(the envelope of an already-refused scope moves; no scope that was served is refused). The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR waits on every check going green, then goes ready and auto-merge.
Generated by Claude Code
- PR shape. Draft, targeting
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsCorrection to the seat's ACCEPT above, on open question 1 (the residue).
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF) · 2026-09-24T19:37ZThe ACCEPT sent the residue to the maintainer. That was wrong: a standing ruling already answers it, so it is execution, ⛔ not a decision.
- Governing text:
packages/spec/src/data/filter-subtree-provenance.ts([A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220, A of the 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 maintainer ruling of 2026-08-12, 「接受你的全部建议。」).- It declares that a refusal from inside a
'policy'subtree 「keeps the 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 redaction: identity (INVALID_FILTER/ 400) … operands in the server log」, and that unmarked ⇒ withheld. - It names
service-analytics'ObjectQLStrategy.withReadScopeas one of the two merge boundaries it covers. - This is the dev's option A2. Option A1 (a 500 at the engine layer) would re-rule 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 B, and the seat does not escalate that on its own motion.
- It declares that a refusal from inside a
- Why the residue survives: the seven refusal doors (engine declared-type, temporal and token doors;
driver-sql's missing-column, operator-vocabulary, combinator and flag refusals) do not read the mark. That is an implementation gap of the ruled design, in the engine and driver lanes. Filed as engine + driver-sql: seven filter-refusal doors ignore the #8220 provenance mark, so a refusal inside apolicyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020. - This card: once PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 lands, it moves to
pm:blockedwithBlocked-by: #20020. When engine + driver-sql: seven filter-refusal doors ignore the #8220 provenance mark, so a refusal inside apolicyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020 closes, the unlock scan re-measures the analytics HTTP doors, and this card closes on that reading. ⛔ Noneeds-user-decision. - Status note, recorded, not decided: the result is a withheld 400 for the residue ([A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220) and a withheld 500 for the analytics compiler's own refusals (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367). Both withhold the policy content.
Generated by Claude Code
- Governing text:
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #20020
Partial landing done; the card waits on the engine-lane residue.
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T20:29Z- PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 →
7b76fff237onorigin/main. Parent count 1.assertReadScopeComparandsRunnableis present inread-scope-sql.tsand is called at both engine-bound merges inobjectql-strategy.ts. The nine comparand-shape classes now answer the withheldREAD_SCOPE_COMPILE_FAILED/ 500 on the ObjectQL face. The PR saidPart of, so the card stays open. - Residue: seven classes refused by engine and
driver-sqldoors that do not read the [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 provenance mark. These are carried by engine + driver-sql: seven filter-refusal doors ignore the #8220 provenance mark, so a refusal inside apolicyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020, which triage gradeddomain:engine·priority:p2·security. Per the correction above, it is execution under the standing 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 / [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 ruling, ⛔ not a decision. - State:
pm:dispatched→pm:blocked(the line above), and the assignee is removed in this stroke. The claim is discharged; the unfinished work is another lane's. - Unlock: when engine + driver-sql: seven filter-refusal doors ignore the #8220 provenance mark, so a refusal inside a
policyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020 closes, the unlock scan re-measures the seven classes through both analytics HTTP doors (POST /api/v1/analytics/query,POST /analytics/dataset/query). No policy content in any body ⇒ this card closescompletedon that reading. Anything else ⇒ back topm:queuewith the measurement. - Also filed from this card: service-analytics: the NativeSQL read-scope compiler and the
/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 (the NativeSQL compiler and echo still compile two shapes the ObjectQL face now refuses).
Generated by Claude Code
- PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 →
31 remaining items
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsLanded. PR #20232 merged through the merge queue as
ab820016b3onorigin/main.domain:servicesseat ·session_01TEah6PeJGjxJfbHaySJjLQ· 2026-09-27T13:50Z- Verified on
origin/main, not from the PR-closed event. The squash has parent count 1.assertReadScopeAdmittedByEngineis inread-scope-sql.ts, and.changeset/19995-judge-filter-read-scope.mdis present. - Ruling C item 2 is delivered. Each engine-bound merge now asks
IObjectQLEngine.judgeFilterabout the read scope alone before composing it:withReadScope,resolveFkAttrand the plugin's record-label fetch. A refused scope answers the withheldREAD_SCOPE_COMPILE_FAILED/ 500. The close condition was measured on both HTTP doors with no policy content in any body, and the at-tier contract review PASS is record5856272000on headd9a1002f. - Board. The closing keyword closed this card
completed.pm:dispatchedand the assigneeos-litantare removed in the same stroke as this note. The claim is discharged, and there is no remainder on this card. - Siblings from the same ruling. Seam:
IObjectQLEnginegains an optional judge-only filter-admission method, run by the engine's own admission pipeline without executing (objectstack#19995 ruling C) #20157 (the engine member) is closed. RLS policies are admitted when they are authored: policy save andobjectstack validate/ compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158 (authoring-time admission) is nowdomain:specand is not affected.
Generated by Claude Code
- Verified on
- added 10 commits that reference this issue
on Sep 28, 2026 - added a commit that references this issue
on Sep 29, 2026 - added a commit that references this issue
on Oct 7, 2026
Ruled: 5852158605 · letter C · 2026-09-27T03:03Z
Blocked-by: #20157
Filing gate ① — a product defect with a named site and a measurement (class b: a declared contract, the #5367 ruling recorded in
read-scope-sql.ts's header, not held on a sibling face).domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — ⏳ vacant #6021).$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 dev on PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994, as an out-of-scope finding.domain:servicesseat (service-analytics). Ruled C (comment 5852158605): this card is the analytics-face execution card, blocked on Seam:IObjectQLEnginegains an optional judge-only filter-admission method, run by the engine's own admission pipeline without executing (objectstack#19995 ruling C) #20157; the authoring-time consumer is RLS policies are admitted when they are authored: policy save andobjectstack validate/ compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158.The contract
The #5367 ruling, recorded in
packages/services/service-analytics/src/read-scope-sql.ts's header: a read-scope refusal is deliberately ⛔ never a 4xx. A 400 echoes its message verbatim and hands the caller 「the FIELD NAMES AND COMPARANDS OF THE RLS POLICY — the one document a tenant must not be able to read out of an error body」. The native-SQL and echo faces hold this: they returnREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld.The defect
The ObjectQL execute face composes the same read scope into the engine's
where(ObjectQLStrategy.withReadScope→ObjectQL.aggregate). A scope carrying a shape the shared comparand face refuses, such as a list in an equality slot, comes back as the engine'sINVALID_FILTER/ 400. Its message names the policy field and quotes the comparand values, for example a resolved membership set. So one scope gets two envelopes across the analytics faces, and the 400 one is the disclosure #5367 closed.Measured (the #19975 dev, a one-time probe on
ae7a35a63b; ⛔ not re-run by this seat)AnalyticsService(objectqlAggregate) over a realObjectQL+SqlDriver(better-sqlite3), with a read scope whose policy compares a field to a list. It throwsINVALID_FILTER/ 400, and the message contains the field name and the list.field == current_user.MEMBERSHIP. PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 ([finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886) would remove that authored producer, but host-supplied scopes remain.Direction
The ObjectQL execute face refuses a read scope it cannot run with the same withheld envelope as the other faces (
READ_SCOPE_COMPILE_FAILED/ 500, fail-closed). ⛔ It does not echo the engine's 400.Governing text: the #5367 ruling (re-affirmed as #7598 Q2 = A), as recorded in
read-scope-sql.ts.Dedupe
One semantic issue search, open and closed, on the query 「analytics objectql strategy read scope INVALID_FILTER 400 error message discloses RLS policy field name and comparand values, withheld 500 on native face」. It returned 42 hits:
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975, [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY asIN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888, [finding]FilterConditionSchemastill PARSES{ field: [...] }and{ field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889, [finding] driver-sql answers an equality-slot array NESTED under$and/$or/$notwith 500 DATABASE_ERROR, while the same shape at top level gets 400 INVALID_FILTER #19885,ObjectQLStrategy.executecannot see a cross-object filter nested in a conjunct, so it accepts a set/analytics/sqlrejects #10759, runtime:POST /analytics/queryrefuses the arraywherethe objectui adapter now sends for every array-form filter —AnalyticsQueryRequestSchema.whereisFilterConditionSchema, whilelowerAnalyticsWhere(the gate ui#6302 measured) accepts filter AST #15828;Dedupe words:
analytics objectql read scope INVALID_FILTER 400·withReadScope engine.aggregate policy field disclosure·read scope two envelopes native 500 objectql 400·5367 withhold objectql strategyGenerated by Claude Code