Repository navigation
service-analytics: the NativeSQL read-scope compiler and the /analytics/sql echo 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
Description
Activity
objectstack-fleet commented on Sep 24, 2026
Blocked-by: #19995
分诊首次定级:priority:p2 · security · bug · domain:services · pm:blocked —— 同一个读范围,在分析服务的不同入口得到不同答案:原生 SQL 执行和 /analytics/sql 回显会编译两种共享检查面拒绝的形状($eq 下的普通对象、$in 里的 null);与 #19995(PR #20017)同一个文件,排在它后面
Path: packages/services/service-analytics/src/read-scope-sql.ts(compileScopedFilterToSql,被 NativeSQLStrategy.applyReadScope 和 ObjectQLStrategy.generateSql 使用)⇄ 契约 @objectstack/spec/data 的 filter-comparand-type.ts / filter-comparand-shape.ts
Triage: lands in service-analytics ⇒ domain:services, security, bug, priority:p2, pm:blocked Blocked-by #19995; rationale: after PR #20017 the ObjectQL execute face refuses a read scope carrying a plain-object comparand under $eq or a null member in $in (READ_SCOPE_COMPILE_FAILED / 500), while the NativeSQL execute face and the /analytics/sql echo compile the same scope through compileScopedFilterToSql — one row-level read scope, two answers across faces, and which rows the compiled SQL selects is NOT measured (security because it is the enforcement of a read scope); same file as PR #20017, so it waits for that landing.
分诊席 #6015,2026-09-24T20:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main 44639665ee 上确认 read-scope-sql.ts 里有 compileScopedFilterToSql。卡面的对照表是 #19995 的开发在 14add487b4 / 9a40317b15 上用桩掉的 executeRawSql 测的,本席没有重跑。
定级说明
- p2 +
security:这是读范围(行级安全)的执行在不同入口不一致。编译出来的 SQL 在数据库里到底选中哪些行,卡面明确写着没测:可能更宽,也可能更窄。在测清楚之前按安全问题处理。 - 不给 p1:由作者写的 CEL 产生 null 成员的那条路已经在
compileCelToFilterlowers a null MEMBER of a membership array straight into an RLS$in, bypassing the fail-closed path a null SCALAR variable takes — and the backends then disagree three ways on what that filter selects #13496 关掉了,剩下的是宿主提供的范围(getReadScope契约)和其它产出方。
为什么挂在 #19995 后面
卡面写明「after PR #20017 lands (same file, read-scope-sql.ts)」。PR #20017 还会在同一个模块里加入 assertReadScopeComparandsRunnable 这个辅助函数,本卡的修法很可能要复用它。#19995 关闭时本卡解锁。
解锁后的执行要点
- 第一步:测数据库层的结果,即这两种形状编译出的 SQL 实际选中哪些行;再测产出方,即哪些宿主或来源会产出这两种形状。
- 方向:同一个范围在每个分析入口得到同一个答案。大概率是让
compileScopedFilterToSql也跑同样的两个检查面。⚠️ 这会让原生入口拒绝它今天还在服务的范围,所以按卡面要求,先有第 1 步的读数再决定。
- 钉住:两种形状在 ObjectQL、原生 SQL、回显三个入口上答案一致。
Generated by Claude Code
objectstack-fleet commented on Sep 24, 2026
Unlock scan: the blocker's reason is gone; the card goes back to pm:queue. domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T20:37Z
- Why it was blocked: it was serial after 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, because PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 was editingread-scope-sql.ts, the same file this card lands in. - PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 landed (
7b76fff237onorigin/main). security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 stays open, but only aspm:blockedbehind 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, whose remaining work is in the engine anddriver-sqlrefusal doors (packages/objectql,packages/drivers/driver-sql). That is ⛔ notread-scope-sql.tsand not this card's surface. - Re-derived:
compileScopedFilterToSql(native / echo) still does not run the shared comparand faces thatassertReadScopeComparandsRunnable(PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017, same module) now runs for the ObjectQL face. The premise holds. - The ordering is a technical dependency between tasks, so the seat re-derives it (no escalation class). The
Blocked-by: #19995line no longer binds. - Not dispatched this minute: the seat's dev slots are full.
Generated by Claude Code
objectstack-fleet commented on Sep 24, 2026
Claim: PM loop round 1
Session: session_01Evb5jFDZGKQE9KG4jbMfMF
Branch: claude/issue-20018-native-read-scope-faces
Worktree: objectstack-issue-20018
Domain: domain:services
Seat: domain:services#1
File surface: packages/services/service-analytics/src/read-scope-sql.ts (compileScopedFilterToSql, reusing assertReadScopeComparandsRunnable from the same module); new test file(s) under packages/services/service-analytics/src/; .changeset/20018-*.md. ⛔ Not filter-normalizer.ts / preview-evaluator.ts (PR #20032). ⛔ No packages/spec. Measurement first: the database-level rows the two shapes select on the native and echo faces, and their producers (host-supplied scopes; CEL closed by #13496). (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 (narrowing)
Thread-read: 5821861549
Serial constraints cleared at 2026-09-24T21:33Z: PR #20017 (#19995, the same module) landed as 7b76fff237. Over all 19 open PRs, none touches read-scope-sql.ts. PR #20032 (#20010) is in filter-normalizer.ts / preview-evaluator.ts, so the surfaces are disjoint.
objectstack-fleet commented on Sep 24, 2026
os-dev-report
{
"issue": 20018,
"status": "done",
"branch": "claude/issue-20018-native-read-scope-faces",
"pr": "#20046",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF",
"premise_still_valid": true,
"summary": "The read-scope compiler, compileScopedFilterToSql, now calls assertReadScopeComparandsRunnable once its own lowering returns. So the NativeSQL execute face and the /analytics/sql echo refuse, as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld, every read scope the shared comparand faces refuse. That is the ObjectQL face's answer since PR #20017, so one scope now gets one verdict on all three faces. Measured first on 9d81af7 with real SQLite and a real ObjectQL engine, the gap was wider than the card's two shapes: null list members and bounds, null ordering comparands, blank $between bounds, bigints beyond 2^53, binary values, and non-scalar objects in scalar positions. For example, native and echo served only the NULL row that a negated null-member scope or a null-member $nin excludes. No producer legitimately authors these shapes: 0 in-repo RLS policies do (grep with a positive control), and the CEL lowering emits them only from predicates the standing rulings refuse. So this proceeded under the rulings, not as needs_decision. The call sits after the lowering, not at the entry the dispatch hypothesised: ablation A2 put it at the entry and 36 existing log-sentence pins went red. Nine existing pins that asserted the lowering binds a refused shape were re-judged with notes. The assignee was os-sales on arrival and was not touched. No labels were written: the role-file set is empty, since skip-changeset does not apply to a package that publishes.",
"tests": "The new file is src/tests/read-scope-comparand-three-faces.test.ts (32 cases). Measurement commit 8c80d9c (test only, pre-fix): "Tests 17 failed | 15 passed (32)". Every refusal class plus the 3 pins built on them are red, and every control is green; the first failing face in each row is the echo ("echo: expected a refusal, got rows: expected [ 'd1' ] to be undefined"; "echo: envelope code: expected 'DATABASE_ERROR' to be 'READ_SCOPE_COMPILE_FAILED'"). Final head 1fd4fe3 (after merging origin/main b373596), pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: "Test Files 119 passed (119)", "Tests 2593 passed (2593)", exit 0. pnpm --filter @objectstack/service-analytics typecheck exits 0, and tsc --noEmit --listFiles lists the new test (1 hit). Ablations ran through scripts/ablation-replace.mjs in WRAP mode. The subject resolves to src/ by relative import, so there is no dist leg. A1 deleted the call: "26 failed | 2567 passed (2593)", which is all 17 negative pins in the new file plus the 9 re-judged pins; controls green; anchor x1 to x0, blob 34705e267dba to dae5a10be27d. A2 inserted the call before compileNode: "37 failed | 2556 passed (2593)", which is the new ordering pin plus 36 existing message pins in 6 files; blob 34705e267dba to 130ea0f8d825. Each restore was proven by the tool: blob after restore == HEAD blob 34705e267dba, git diff HEAD empty, porcelain clean. The first A2 attempt was a void run: its replacement contained its anchor, so the tool refused with "anchor count moved 1 -> 1", restored, and measured nothing. It was re-run with a 3-line anchor. Gates at 1fd4fe3: dispatch-gates derived 61 families, not stale and a superset of the 48-line dispatch-time list; all 61 ran. check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3); after building every ./packages/** package (VERDICT command-exit 0) both exit 0. The --ran reconciliation reads "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero; every record carries an exit code). check-issue-citations with GITHUB_TOKEN: exit 0, 8 judged, 8 resolve. check-adr-0087-registration --base 9d81af7: exit 0, not-required (no-migration-prescription). check:nul-bytes: exit 0, and a control-byte self-scan of the 9 changed files found none. Lint narrowed with eslint --no-inline-config --format json over the 8 touched .ts files: 8 read, 0 errors, 0 warnings, none ignored. The changeset .md is outside eslint's population. eslint.config.mjs has no parserOptions.project and no projectService, so there is no type-aware linting and no untouched file's verdict can move. pnpm lint itself is CI's.",
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "2 relay strokes (POST /repos/objectstack-ai/objectstack/dispatches, each executed by the fleet-write workflow as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft), run 36071278807 success, PR #20046; (2) this os-dev-report comment → POST /repos//issues/20018/comments. Labels: 0 writes. git push is not a REST write. All other REST calls were GETs (issue, comments, PR 20017, issues 13496 / 20010, PR read-back).",
"open_questions": [],
"out_of_scope_findings": [
"class: c · The RLS authoring surface admits predicates that lower into read-scope shapes the shared comparand faces refuse. Measured on 9d81af7: f in ['a', null], f in [null], !(f in ['a', null]), f > null, f <= null and f != current_user each pass isSupportedRlsExpression, compileCelToFilter and RLSCompiler.compileFilter lower each one verbatim ($in with a null member, $gt: null, $ne with the whole user object), and validateRlsPredicateEnforceability returns 0 findings. The runtime then refuses each as READ_SCOPE_COMPILE_FAILED / 500 on every analytics face (the ObjectQL face before this PR, all three after). Named producer: the stored permission-set rowLevelSecurity using/check predicate, and its lint in packages/lint/src/validate-rls-predicate-enforceability.ts. Seam: spec:assertListComparandShapes / normalizeFilterComparandTypes → runtime:RLSCompiler.compileFilter (plugin-security) and compileScopedFilterToSql / withReadScope (service-analytics); consumer: the analytics read-scope faces. Dedupe words: RLS predicate null list member lint · compileCelToFilter literal null in list · validateRlsPredicateEnforceability refused comparand shape · ordering comparison against null RLS",
"carrier: PR #20032 (#20010) · The caller-where door accepts a null $in member (the #8186 matrix cell whereIn for null stays accept). This PR does not touch that door, and its control pins only that a caller where answers as it does with no scope. Noted, not filed.",
"carrier: 承接者:无 · The CRUD read path is not measured: the security middleware composes the same RLS filter into the engine where after the engine's shared-face seam, so what driver-sql answers for these shapes there is outside this card. Noted in the Acceptance notes only."
],
"deviations": [
"The dispatch's mechanism hypothesis 3 placed the call "at the entry of compileScopedFilterToSql". It is placed after the lowering instead, measured by ablation A2 (36 existing log-sentence pins go red at the entry); the verdict set is identical either way. Reported per the role file, not silently chosen.",
"Outside the expected landing list: a comment-only paragraph on the binary extra in src/comparand-shape.ts, which would otherwise state "accepted in every bind position" untruthfully for the read-scope door. It is not a forbidden file.",
"origin/main (b373596, 3 commits in packages/objectql and plugin-security) was merged into the branch before the final runs, per AGENTS.md multi-agent section 10."
],
"pr_body_new": "Fixes #20018\n\nClause-②: no (narrowing)\n\n## What changed\n\ncompileScopedFilterToSql (packages/services/service-analytics/src/read-scope-sql.ts) is the read-scope lowering behind two analytics faces:\n\n- the NativeSQL execute face, NativeSQLStrategy.applyReadScope, for the base table and every joined hop;\n- the /analytics/sql echo, ObjectQLStrategy.generateSql.\n\nIt is also a public export of the package. Once its own lowering returns, it now calls assertReadScopeComparandsRunnable on the scope. That is the helper PR #20017 added in the same module, and it runs @objectstack/spec/data's assertListComparandShapes and normalizeFilterComparandTypes on the scope alone.\n\nA scope those faces refuse now gets READ_SCOPE_COMPILE_FAILED / 500 with the message withheld (the #5367 ruling, re-affirmed as #7598 Q2 = A) on the native face and the echo. The refusal comes before any statement is built or executed. That is the answer the ObjectQL execute face has given since PR #20017, so one read scope gets one verdict on all three analytics faces.\n\n- Files: the call itself is one line. The rest of the diff is:\n - the module-header section (#20018);\n - a note on the helper's docblock;\n - a one-paragraph note on the binary extra in comparand-shape.ts, whose "accepted in every bind position" is no longer true of the read-scope door;\n - tests and the changeset.\n- Not touched: objectql-strategy.ts and native-sql-strategy.ts. Both faces reach the guard through the compiler, so neither needs a call site of its own.\n- Also not touched: filter-normalizer.ts, preview-evaluator.ts and packages/spec.\n\n## Measurement, recorded before the fix\n\nEverything in this section was measured on 9d81af714f (origin/main, pre-fix). The rows are executed, not read from the compiled string.\n\n- Harness: a scratch probe that is not committed, plus measurement commit 8c80d9c3d2 (the new test file alone).\n- Database: one real SqliteWasmDriver with four fixture rows. region is NULL on d3, and owner and amount are NULL on d4.\n- ObjectQL face: a real ObjectQL engine.\n- Echo: its SQL was run on the same database.\n- Native: NativeSQLStrategy.execute through executeRawSql on the same database. It was not stubbed.\n- Scopes: the getReadScope contract, filled by hand.\n\n| read-scope shape class | ObjectQL execute | echo (SQL executed) | native execute |\n|:--|:--|:--|:--|\n| plain-object comparand under $eq (the card's first shape) | READ_SCOPE_COMPILE_FAILED / 500 | compiles; the database refuses the bind | DATABASE_ERROR / 500 |\n| null member in $in, ['emea', null] (the card's second shape) | 500 | d1 | d1: the NULL member matches nothing |\n| null-only $in | 500 | no rows | no rows |\n| null member in $in under $not | 500 | d3 | d3: only the NULL row, which the scope names as excluded |\n| null member in $nin | 500 | d3 | d3: only the NULL row, which the scope names as excluded |\n| null comparand under $gt / $lte | 500 | no rows | no rows |\n| null $between bound | 500 | no rows | no rows |\n| blank $between bound | 500 | d1 d2 d4 | d1 d2 d4 |\n| plain-object comparand under $ne / $gt; empty object under $eq | 500 | compiles; DB refuses | DATABASE_ERROR / 500 |\n| bigint beyond 2^53 (implicit, $in) | 500 | no rows | no rows |\n| binary comparand, binary $in member | 500 | no rows | no rows |\n| Map or function comparand | 500 | compiles; DB refuses | DATABASE_ERROR / 500 |\n| controls (9 well-formed scopes, including the null predicates and the live RLS composite) | the same rows on all three faces | | |\n\nThe card named two shapes. The measurement found the gap is every shape the two shared faces refuse and this compiler's own gates did not: null list members and bounds, null ordering comparands, blank bounds, oversized bigints, binary, and non-scalar objects in a scalar position. That is the set that moves.\n\n### The mechanism hypotheses\n\n- Hypothesis 1 is confirmed, and the set is wider than the card's two shapes. These are the compile arms that accepted them:\n - case '$eq': return val === null ? … : `${col} = ${bind(params, val)}`;. A plain object is bound, and no gate judges a $eq comparand's type. assertNoFieldReferenceComparand steps past an object that is not { $field: string }.\n - case '$in' → assertCompilableMembers(op, field, val) → isBindableComparand(member), which admits null (an accepted comparand type) and binary (a package-local extra), then IN (…) binds each member.\n - The ordering arms and $between bind whatever passes those gates. The implicit-equality arm binds any non-object.\n- Hypothesis 2: measured. See the table above, and the producers section below.\n- Hypothesis 3 is confirmed in verdict, but the placement is better than "at the entry". The two walks are pure functions of the scope, so the placement cannot change which scopes are refused, only which log sentence a doubly-refused scope carries.\n - Ablation A2 below put the call at the entry and turned 37 tests red in 7 files: the new ordering pin, plus 36 existing log-sentence pins, for example read-scope-eq-array-refusal (15) and read-scope-undefined-comparand (8).\n - After the lowering, every shape this compiler already refused keeps its own sentence (the #13926 ordering), and the faces add only what would otherwise have been lowered.\n\n### Producers: who can emit these shapes today\n\nAll readings below are on 9d81af714f.\n\n| producer | emits a refused shape? | evidence |\n|:--|:--|:--|\n| RLS using predicates, through compileCelToFilter → RLSCompiler.compileFilter | yes, from an authored predicate. f in ['a', null], f in [null], !(f in ['a', null]), f > null, f <= null and f != current_user each lower verbatim into a refused shape. | Scratch probe. All six pass isSupportedRlsExpression, and validateRlsPredicateEnforceability returns 0 findings for each. |\n| authored policies in this repository | none | git grep of using / check predicates for a null list member or a null ordering comparand, over examples/** and packages/**/*.ts (tests excluded): 0 matches, exit 1. The control on the same tree and file set, predicates naming current_user, matched 77 lines in 7 files, exit 0. |\n| a resolved membership variable with a null member | no | The #13496 guard. Measured: f in current_user.teams with a null member lowers to the deny sentinel. |\n| plugin-sharing buildReadFilter | no | Owner ids are String(userId) or a resolver's string[]. grantedRecordIds filters out null and ''. |\n| a host getReadScope option, or a direct caller of the export | anything | The door the card names. |\n\nVerdict: no producer both legitimately authors one of these shapes and relies on the native answer. Three facts support that:\n\n- The in-repo producer emits these shapes only from predicates that the standing rulings, carried by the shared faces, refuse. No policy in this repository is one of them.\n- The ObjectQL analytics face already refused every such scope.\n- The native answer was not the predicate's meaning. It dropped the null member, admitted only the NULL rows the scope excludes, returned zero rows, or hit a database error.\n\nSo this is execution under the rulings, not a needs_decision. The authoring half is reported separately as a finding; see the Acceptance notes.\n\n## Tests\n\nThe new file is packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts, with 32 cases. It uses one SqliteWasmDriver, a real ObjectQL behind the ObjectQL face, and the echo's SQL executed.\n\n- 13 refusal classes. Each asserts on all three faces: code READ_SCOPE_COMPILE_FAILED, status 500, and the prose withheld. "Withheld" means serverFaultProvenance(resolveThrownHttpError(err, 500)) is 'declared' and declaredRefusalMessage(err) is undefined.\n- The native face refuses before any statement reaches the database. Zero executeRawSql calls across all 13 classes.\n- The joined hop. applyReadScope's per-hop lowering refuses a joined object's scope, named for that hop.\n- The public export refuses every class, and a well-formed scope compiles to the same bound predicate as before.\n- Log sentences. A shape the compiler already refused, a list under $eq, keeps its own sentence. A shape only the faces refuse carries their sentence, the same on all three faces.\n- Controls:\n - with no scope, every face serves all four rows;\n - 9 well-formed scopes admit the same rows on every face. They include { region: null }, $ne: null, a non-empty $nin, the live RLS composite with an emptied $in beside an own-rows grant, and the spelling the null-member ruling prescribes ($or of $in and $null: true);\n - a well-formed caller where composes with a well-formed scope;\n - a caller where in a refused scope shape answers exactly as it does with no scope, and is never attributed to the read scope.\n\nExisting pins that asserted the lowering binds a refused shape. Each is re-judged with a [#20018] note, and each keeps what it was there to pin:\n\n- comparand-door-single-source.test.ts. Three matrix cells now read READ_SCOPE_COMPILE_FAILED/500: null under scopeIn, binary under scopeIn and scopeEq, and plain object under scopeEq. The where-door cells are unchanged. The "six accepted types, every position" check names the one cell a shape ruling moved.\n- comparand-shape-refusal.test.ts. "Keeps binding every legitimate $in member" drops null, and a new case pins null refused with the face's sentence.\n- cross-field-reference-refusal.test.ts. { $gt: { $field: 5 } } is refused as a plain object, with the type face's sentence and not the field-reference gate's.\n- read-scope-boolean-flag-comparand.test.ts. An inherited $null still cannot trip the flag gate, because the refusal carries no flag sentence. The object is refused by the type face as a non-plain value.\n- read-scope-undefined-comparand.test.ts. $in: [null], $nin: [null] and $between: [null, 5] leave the null control group for their own "refused by the shared face" block. Every null predicate stays in the group, unmoved.\n\nResults:\n\n| run | tree | result |\n|:--|:--|:--|\n| new file, measurement commit (test only) | 8c80d9c3d2 | Tests 17 failed \\| 15 passed (32). Every refusal class and the three pins built on them are red; every control is green. The first face to fail in each row is the echo; the ObjectQL face passed first. |\n| whole package | 1fd4fe3e37 (final) | Test Files 119 passed (119), Tests 2593 passed (2593) |\n| pnpm --filter @objectstack/service-analytics typecheck | 1fd4fe3e37 | exit 0; tsc --noEmit --listFiles includes the new test file |\n\nAblations. Both ran through node scripts/ablation-replace.mjs in WRAP mode (the anchor must hit exactly once, the blob must change, and the tool's own trap restores). The subject resolves to src/ through relative imports, so no dist/ leg applies. Each restore was proven: blob 34705e267dba equals HEAD, and git diff HEAD is empty.\n\n| leg | mutation | result (whole package) |\n|:--|:--|:--|\n| A1 | delete the new assertReadScopeComparandsRunnable(filter, alias); call | 26 failed \\| 2567 passed (2593): all 17 negative pins in the new file, plus the 9 re-judged pins (3, 1, 1, 1 and 3 across the five files). Every control stayed green. |\n| A2 | call it BEFORE compileNode instead of after | 37 failed \\| 2556 passed (2593): the new ordering pin, plus 36 existing log-sentence and precedence pins in 6 files |\n\nThe first attempt at A2 was a void run. Its replacement text contained its anchor, so the tool refused with "anchor count moved 1 -> 1" and restored. Nothing was measured. It was re-run with a three-line anchor, and that is the result above.\n\n## Gates\n\nDerived gates. Derived at 1fd4fe3e37 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 61 families, not stale. They are a superset of the 48-line dispatch-time list.\n\n- 59 exited 0.\n- check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3), because the workspace had no dist/. After building every ./packages/** workspace package (VERDICT command-exit 0), both exited 0: check:dual-build-cjs-loads passes its floors, and check:type-check-debt re-measured 4 ledger entries with none above its recorded number.\n- The --ran reconciliation: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. That zero is derived, not claimed: all 61 records carry an exit code and none is 3.\n\nOther checks:\n\n- GITHUB_TOKEN=… node scripts/check-issue-citations.mjs: exit 0; 8 citations judged, 8 resolve.\n- node scripts/check-adr-0087-registration.mjs --base 9d81af714f: exit 0. The changeset is BREAKING+bang+clause-②-narrowing, disposition not-required (no-migration-prescription).\n- Lint, narrowed to the 8 touched TypeScript files with eslint --no-inline-config --format json at 1fd4fe3e37: 8 files read, 0 errors, 0 warnings, none ignored.\n - The changeset is outside eslint's population ("no matching configuration").\n - eslint.config.mjs enables no type-aware linting: its parserOptions carry only ecmaVersion and sourceType, with no project and no projectService. So this diff cannot move a verdict on an untouched file.\n - pnpm lint itself is CI's.\n\n## Acceptance notes\n\n- BREAKING, minor with !. Read scopes the native face and the echo used to serve are refused now; the table above lists what they served. The changeset carries the banner and the fix spellings: the null predicate beside a membership, $gte / $lte for a one-sided range, and a scalar comparand.\n- The refusal set is the shared faces' set, not only the card's two shapes. Judging the scope with the same function the ObjectQL face uses is what makes the verdicts equal. A narrower hand-picked subset would have left the other rows of the table as "one scope, two answers".\n- Binary. The package-local binary bindable (isBindableComparand) no longer reaches the read-scope door. The shared type face refuses binary, and the ObjectQL face already did. The where door and the predicate itself are unchanged.\n- The joined-hop log sentence names the alias. compileScopedFilterToSql knows only the alias, so the operator's log reads read scope for \"ALIAS\": the object name on the base table, the join alias on a hop. The response withholds it either way.\n- Precedence on the native face. When a scope carries both a shape this compiler refuses and one only the faces refuse, the compiler's sentence answers. The verdict is the same either way.\n- The CRUD path is not an analytics face and was not measured here. The security middleware composes the same RLS filter into the engine's where after the engine's shared-face seam has run. What driver-sql answers for these shapes there is outside this card.\n- Finding, class (c), not fixed here, for the seat to file. The RLS authoring surface admits predicates that lower into scope shapes the shared faces refuse: a literal null in an in list, an ordering comparison against null, or a comparison against the whole current_user object. All three pass isSupportedRlsExpression, and validateRlsPredicateEnforceability returns no finding for them. They are refused (500) on every analytics face after this PR. The evidence is in the producers table.\n- origin/main was merged in at b3735968ba. Its three commits touch packages/objectql and packages/plugins/plugin-security, and share no path with this diff. @objectstack/objectql was rebuilt before the final runs.\n- Files not touched: filter-normalizer.ts, preview-evaluator.ts, objectql-strategy.ts, native-sql-strategy.ts, packages/spec.\n- #19995 stays open behind #20020, for its engine- and driver-door residue. This PR does not address it.\n"
}
Generated by Claude Code
objectstack-fleet commented on Sep 24, 2026
os-dev-report
{
"issue": 20018,
"round": 2,
"status": "done",
"branch": "claude/issue-20018-native-read-scope-faces",
"pr": "#20046",
"head": "0fbed27877e5a70ec591352d32cc3250bf4fe64d",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF",
"summary": "Merged origin/main (adbbc5d: #20032 / #20010 and #20038) into the branch; no rebase, no force-push. The one content conflict, in comparand-door-single-source.test.ts, is resolved so both sets of re-judgements hold. The null row now reads whereIn INVALID_FILTER/400 (#20010) and scopeIn READ_SCOPE_COMPILE_FAILED/500 (#20018), with both notes kept. The six-accepted-types check is now a table of moved cells keyed by the change that moved each (null whereIn → #20010, null scopeIn → #20018), and every other cell is held at accept. The auto-merged comparand-shape-refusal.test.ts and cross-field-reference-refusal.test.ts were re-read on the merged tree. #20032 edited only where-door pins and this PR only read-scope pins, and the notes agree: the null member is refused by the same 2026-08-31 ruling at each door, each in its own envelope. One stale sentence ("it binds as JSON", in the non-string $field case) was scoped to the where door in a follow-up commit. The PR is now mergeable (mergeable: true).",
"tests": "At head 0fbed27, whole package pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: "Test Files 120 passed (120)", "Tests 2689 passed (2689)", exit 0. typecheck: exit 0, and tsc --noEmit --listFiles lists the new test. Ablation A1 re-run at 0fbed27 through scripts/ablation-replace.mjs WRAP (delete the call): "26 failed | 2663 passed (2689)". That is the same 17 + 9 distribution as round 1 (17 in the new file; 3, 1, 1, 1 and 3 in the re-judged files); anchor x1 to x0, blob 34705e267dba to dae5a10be27d. Restore proven: blob == HEAD 34705e267dba, git diff HEAD empty, porcelain clean. A2 stands from f293340: read-scope-sql.ts is the same blob (34705e267dba) on both trees. Gates re-derived at 0fbed27 (not stale, merge base adbbc5d): the same 61 families, all run. check:dual-build-cjs-loads and check:type-check-debt answered PREREQUISITE NOT MET (exit 3) on the fresh worktree, and after building every ./packages/** package (VERDICT command-exit 0) both exit 0 (type-check-debt: 4 entries, 53 raw errors, none above record). --ran reads "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero; every line carries an exit code). check-issue-citations with GITHUB_TOKEN: exit 0, 8 judged, 8 resolve. eslint narrowed over the 8 touched .ts files: 8 read, 0 errors, 0 warnings, 0 ignored. CI on 0fbed27 at report time: 38 check runs, 13 success, 5 skipped, 20 in_progress, 0 failure (in_progress is not a pass).",
"mcp_calls": "0",
"api_writes": "2 relay strokes this round (POST /repos/objectstack-ai/objectstack/dispatches, run as objectstack-fleet[bot]): (1) issue_patch → PATCH /repos//issues/20046, body only, run 36074706677 success; read back identical, 17434 bytes, one footer; (2) this addendum → POST /repos//issues/20018/comments. Plus 1 git push (1fd4fe3..0fbed27). The seat granted the one PR-body edit; the role file's write-once rule is otherwise kept.",
"out_of_scope_findings_changed": "Round 1 named "carrier: PR #20032 · the caller-where door accepts a null $in member". That no longer holds: #20032 landed and the where door refuses it (INVALID_FILTER/400). The RLS-authoring finding is the seat's to carry and was not filed.",
"pr_body_sections_changed": [
"Tests: the re-judged-pins bullet for the matrix names both null-row cells.",
"Tests: the results table cites 0fbed27 (120 files, 2689 tests); A1 re-run there; A2 marked as run at f293340 on the same blob.",
"Gates: derived at 0fbed27; the two prerequisite gates re-measured after the build; --ran line updated; the lint and adr-0087 lines cite 0fbed27.",
"Acceptance notes: the merge note covers both merges and the conflict resolution."
]
}
Generated by Claude Code
objectstack-fleet commented on Sep 24, 2026
Review — ACCEPT (landing gated on CI) · PR #20046 · head 0fbed27877 · domain:services seat · 2026-09-24T23:53Z
Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main (7e6ca1787a), ⛔ not against the report's own account.
Checklist
-
PR shape. Draft, targeting
main. The first line isFixes #20018, andClause-②: no (narrowing)starts its own line. No other closing keyword. -
Scope. 9 files, +691 / −31, all in
packages/services/service-analytics/plus the changeset.read-scope-sql.ts: one call and header notes.- A comment-only note in
comparand-shape.tson the binary extra. - One new 32-case test file, and five re-judged pin files.
Not
objectql-strategy.ts/native-sql-strategy.ts(both faces reach the guard through the compiler), notfilter-normalizer.ts/preview-evaluator.ts, nopackages/spec. NOT governed. It merges clean overorigin/mainafter round 2. -
Diff, read line by line.
compileScopedFilterToSqlcallsassertReadScopeComparandsRunnable(filter, alias)once its own lowering returns.- A scope the shared faces refuse therefore gets
READ_SCOPE_COMPILE_FAILED/ 500, withheld, on the native face and the echo, before any statement is built. - That is the ObjectQL face's answer since PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017: one scope, one verdict on all three faces.
- Placement after the lowering is measured, not assumed. Ablation A2, the entry placement, turns 36 existing log-sentence pins red. After the lowering, each shape this compiler already refused keeps its own sentence (the analytics: read-scope-sql's ruled $not-over-$in-empty residue has no open card — and #13640 turned it into an echo-vs-execution disagreement on the ObjectQL strategy #13926 ordering). The verdict set is identical either way.
- A scope the shared faces refuse therefore gets
-
Measurement. Executed rows on a real SQLite, with native
executeRawSqlnot stubbed.- The gap was every shape the two faces refuse: the card's two, plus null bounds and ordering comparands, blank bounds, oversized bigints, binary, and non-scalar objects.
- Natively, a negated null-member scope and a null-member
$ninserved ONLY the NULL row the scope excludes. - No producer legitimately authors these shapes: 0 in-repo policies, with a positive control of 77.
-
Round 2 (the merge conflict with PR fix(service-analytics)!: the analytics
wheredoor runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032). Resolved so both re-judgements hold. Thenullrow readswhereIn→INVALID_FILTER/400(service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010) andscopeIn→READ_SCOPE_COMPILE_FAILED/500(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 six-accepted-types check is now a keyed table of moved cells, each named with the change that moved it. I read the resolved hunk myself. -
Tests. The measurement commit went red first: 17 red / 15 green. The merged head: package 2689 green, typecheck 0. Ablation A1 on the merged head: 26 red (17 + 9), same as round 1, and the restore is blob-identical.
Deviations accepted. The call is placed after the lowering, not at the entry (measured, A2). There is a comment-only note in comparand-shape.ts. The claim's file surface is revised to include comparand-shape.ts and the five re-judged pin files (this line is the revision).
Out-of-scope findings, one line each:
- The RLS authoring surface admits predicates that lower to refused shapes, and the lint is silent: carried on lint:
validate-rls-predicate-enforceabilityis silent on==/!=against a kernel membership key (current_user.org_user_ids…), a policy the runtime drops on every request once PR #19947 lands #19951 (the lint's card), withf != current_usercross-referenced to [finding] A CEL row-level predicate comparing a field to the barecurrent_userroot lowers against the whole caller object; acheckwritten!= current_useradmits every write #19959. - The CRUD read path for these shapes is not an analytics face: Acceptance notes.
Landing. No Clause-② limb is hit: no packages/spec/src, and the declaration is a narrowing with a BREAKING paragraph and the honest ADR-0087 marker. The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR goes ready and auto-merge when every check is green.
Generated by Claude Code
objectstack-fleet commented on Sep 25, 2026
Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-25T00:28Z
- PR fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046 →
980bc05e5bonorigin/main. Parent count 1 (a queue squash).compileScopedFilterToSqlcallsassertReadScopeComparandsRunnable(filter, alias)atread-scope-sql.ts:572at that commit. The card closedcompletedthrough the PR's one closing keyword. - One read scope now gets one verdict on all three analytics faces. The native execute face and the
/analytics/sqlecho refuse a scope that the shared comparand faces refuse withREAD_SCOPE_COMPILE_FAILED/ 500, message withheld, before any statement is built. That matches the ObjectQL face since PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Carried off this card:
- The RLS authoring finding, on lint:
validate-rls-predicate-enforceabilityis silent on==/!=against a kernel membership key (current_user.org_user_ids…), a policy the runtime drops on every request once PR #19947 lands #19951. - The CRUD read path for these shapes, in the PR's Acceptance notes.
- service-analytics: the object-form analytics
whereskips the shared comparand-TYPE face, so a plain-object / Map / oversized-bigint comparand is bound as JSON text on the native path while the FilterArray spelling and the engine refuse 400 #20035 (PR fix(service-analytics)!: the analytics where door runs the shared comparand-TYPE face on the object spelling (#20035) #20058), which re-judges the same matrix cells on thewhereside, mergesmainnext.
- The RLS authoring finding, on lint:
Generated by Claude Code
Filing gate ① — a product defect with a named site and a measurement (class b: two analytics faces answer one read scope differently, against the shared comparand faces' declared contract).
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — 🟢 os-bill #6021).INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 dev on PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017, as an out-of-scope finding.domain:servicesseat, after PR fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope #20017 lands (same file,read-scope-sql.ts).The contract
The shared comparand faces in
@objectstack/spec/data, which the engine runs on every object-formwhere:filter-comparand-type.ts(normalizeFilterComparandTypes): 「A comparison value must be a string, number, bigint, boolean, null or Date」.filter-comparand-shape.ts(assertListComparandShapes): the null-member refusal, 「Operator "$in" … does not accept null as a list member」.The defect
After PR #20017, the ObjectQL execute face judges a read scope against both faces and refuses these two shapes as
READ_SCOPE_COMPILE_FAILED/ 500. The NativeSQL execute face (NativeSQLStrategy.applyReadScope) and the/analytics/sqlecho (ObjectQLStrategy.generateSql) go throughcompileScopedFilterToSqlinpackages/services/service-analytics/src/read-scope-sql.ts, which compiles them:$eqREAD_SCOPE_COMPILE_FAILED/ 500$inMeasured by the #19995 dev's probe on
14add487b4and9a40317b15withexecuteRawSqlstubbed. ⛔ Not re-run by this seat. The database-level outcome (which rows the compiled SQL selects) is NOT measured. That is the first step for whoever takes this.Reachability
The authored-CEL producer of a null membership member was closed on #13496, so this card covers host-supplied scopes (the
getReadScopecontract) and any other producer. Measure the producers first.Direction
One scope, one answer on every analytics face. Likely this means⚠️ That refuses on the native face a scope that face serves today, so measure the rows and the producers before choosing.
compileScopedFilterToSqlruns the same two faces on the scope (the helper PR #20017 adds,assertReadScopeComparandsRunnable, is in the same module).Dedupe
One semantic issue search, open and closed, on 「analytics native SQL read scope compiler compileScopedFilterToSql accepts null member in $in or plain object comparand under $eq that the shared comparand face refuses」: 29 hits. Adjacent: #19995 (the ObjectQL face), #20010 (the caller-
wheredoor's other arms), #13496 (closed, the CEL producer), #19975 (closed,$eqlist). None covers the native compiler on these two shapes.Dedupe words:
read-scope compiler object comparand $eq binds·compileScopedFilterToSql null member $in·native sql read scope comparand type face·echo renders scope ObjectQL refusesGenerated by Claude Code