You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
read-scope-sql compiles $eq: [...] in a policy scope as col = ? with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975
Filing gate: ① a product defect with a named landing site. Read from source by the at-tier contract review of PR #19882 (record 5808368753, ①.10, face 3), filed by the domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d) as remedy (a) of that record. Filed unassigned and unlabelled: routing and grading are triage's.
Hand that acts: the lane triage routes this to (the landing is read-scope-sql.ts, the read-scope / RLS compiler). The first step is a measurement, below.
Dedupe (including closed): read-scope-sql compileScopedFilterToSql array comparand $eq bound read scope policy filter → 9 hits, all closed. Among them #19885 (driver-sql nested arrays), #14329, #13926, #6387 and #5297 are other read-scope divergences. None covers the equality-slot array.
The defect (a source reading; ⛔ not executed yet)
Ruling 乙 on #19757 (5793368540) refuses an array in the implicit-equality slot at the shared comparand-shape face 「for every driver at once」. read-scope-sql.ts's compileScopedFilterToSql and its callers (native-sql-strategy.ts:654, objectql-strategy.ts:559) call neither parseFilterAST nor assertListComparandShapes, so the shared face never sees a policy scope:
a bare array is refused through readScopeCompileError = READ_SCOPE_COMPILE_FAILED / 500, fail-closed (:755, :436-440);
$eq: [...] compiles to col = ? with the array bound as the parameter (:1273).
Whether a policy scope can carry that shape upstream, and what the bound array does on each SQL dialect, were ⛔ not traced.
Remedy shape (for the implementing round to confirm)
Measure first: can an authored policy / sharing scope reach compileScopedFilterToSql with $eq: [...] or a bare array, and what does each dialect answer? Record commands and output.
If it is reachable, refuse it with the shared face's INVALID_FILTER / 400 wording at the policy's authoring door, and fail closed at compile. A read-scope compiler must never bind an array into an equality.
If it is not reachable, record the evidence and close this card as not planned.
Triage: lands in the analytics read-scope (RLS) compiler ⇒ domain:services, security, bug, priority:p2, pm:queue; rationale: a row-level read scope compiles an array into an equality with the array bound as one parameter, outside ruling 乙's shared face, and what each SQL dialect then answers is untraced — a read-scope compiler whose output on a shape is unknown is a security question until measured; the card's first step is that measurement, so it is dispatchable now (PR #19882 does not touch service-analytics).
Claim: PM loop round 1
Session: session_01Evb5jFDZGKQE9KG4jbMfMF
Branch: claude/issue-19975-read-scope-eq-array
Worktree: objectstack-issue-19975
Domain: domain:services
Seat: domain:services#1
File surface: packages/services/service-analytics/src/read-scope-sql.ts, region only: the $eq arm of the scoped-filter compiler and its bare-array / readScopeCompileError path; its callers in strategies/native-sql-strategy.ts and strategies/objectql-strategy.ts only if the refusal must sit there; new test file(s) under packages/services/service-analytics/src/; .changeset/19975-*.md. ⛔ No packages/spec: if the authoring-door refusal belongs in the spec policy schema, stop and report, because that is spec-lane work. ⛔ No plugin-security step-3.6 region (PR #19988 is in flight there). Measurement-first: no fix is written before the reachability measurement is recorded. (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: 5815993148
Serial constraints cleared: no open PR in any lane touches packages/services/service-analytics/ (all open PRs' file lists intersected this fire). #19888 (same package, strategies/filter-normalizer.ts) is pm:blocked behind #19757, so it is not in flight. PR #19882 (#19757, ruling 乙's shared face) touches spec, driver-memory, driver-mongodb and metadata-core, not service-analytics. The last merge to read-scope-sql.ts on origin/main is fd014b1713 (2026-09-06T07:50Z); there is no same-day churn.
os-dev-report
{
"issue": 19975,
"status": "done",
"branch": "claude/issue-19975-read-scope-eq-array",
"pr": "#19994",
"head_sha": "11c11c7dc3",
"base_sha": "ae7a35a63b (origin/main when the worktree was cut; the dispatch named 3fd3a4f, and main had moved one commit, which touches no surface of this card)",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat's session; this run is its subagent)",
"premise_still_valid": true,
"summary": "Measured first, on this branch, before any source change (commit c647adb). Two findings. (1) The bare-array spelling IS reachable from authored metadata: a CEL using predicate field == LIST_LITERAL or field == current_user.MEMBERSHIP passes the RLS schema and the lint decision procedure, lowers to { f: [...] }, and reaches the compiler through SecurityPlugin.getReadFilter. There it was already refused fail-closed, READ_SCOPE_COMPILE_FAILED / 500. (2) $eq: [...] is NOT reachable from any authored policy or in-repo producer: the CEL lowering emits $eq only around { $field }. It is reachable only from a host-supplied getReadScope, which is a documented option, or from a direct caller of the export. When it arrived it compiled to col = ? with the list bound. On the NativeSQL execute path, better-sqlite3 answered 500 DATABASE_ERROR, sql.js answered zero rows (every row under $not), and PostgreSQL 16 served the row whose text equals pg's array-literal serialisation (every row under $not). Per the dispatch's not-reachable branch, the compile-time refusal is cheap and pins the guarantee, so it landed. The precedent is #6387, which had the identical reachability profile. The change adds a gate, assertNoListInEqualitySlot, in compileField. It runs ahead of the member gates and refuses a list under $eq at any depth, with this module's envelope READ_SCOPE_COMPILE_FAILED / 500. There are 29 new pins, including both strategy faces over a real engine; the refusal inventory gains row 16 (16 rows over 14 sites); and one frozen-matrix cell is re-judged. Two ablations went red and were restored with proof.",
"clause2": "The PR body carries the claim's line verbatim: Clause-②: no. The seat sets the arm. Measured: this IS a narrowing of the compiler's accept set. { f: { $eq: [...] } } compiled before and is refused now. No authored or in-repo producer emits it. Before the change it "worked" only as zero rows (sql.js), the wrong rows (PG), or a 500 (better-sqlite3). Precedent from the #19886 2a review: a narrowing wants no (narrowing), BREAKING plus the ADR-0087 marker, shipped as minor. The changeset is currently a plain patch with no Clause-② line, and the seat decides.",
"reachability_measurement": {
"how": "Scratch probe scripts (never committed) in the session scratchpad. They were run with node at ae7a35a, against a turbo build of the plugin-security and service-analytics closures (pnpm turbo run build --filter=@objectstack/plugin-security... --filter=@objectstack/service-analytics... --concurrency=2 under the verify lock: 19 tasks, exit 0). Leg C boots a real ObjectQL + SqlDriver(better-sqlite3) + SecurityPlugin whose permission sets carry the using predicate, with context.rlsMembership.team_ids = [t1, t2].",
"authoring_door_and_lowering_and_producer": [
"A "record.status == ['open', 'pending']" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.team_id == current_user.team_ids" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.status != ['closed', 'archived']" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.status == record.prior_status" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "['open'] == record.status" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"B "record.status == ['open', 'pending']" -> {"ok":true,"filter":{"status":["open","pending"]}}",
"B "record.team_id == current_user.team_ids" -> {"ok":true,"filter":{"team_id":["t1","t2"]}}",
"B "record.status != ['closed', 'archived']" -> {"ok":true,"filter":{"status":{"$ne":["closed","archived"]}}}",
"B "record.status == record.prior_status" -> {"ok":true,"filter":{"status":{"$eq":{"$field":"prior_status"}}}}",
"B "['open'] == record.status" -> {"ok":true,"filter":{"status":["open"]}}",
"C "record.status == ['open', 'pending']" getReadFilter -> {"status":["open","pending"]}",
"D compileScopedFilterToSql(that scope) -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"status\" — use { $in: [...] } (fail-closed)."",
"C "record.team_id == current_user.team_ids" getReadFilter -> {"team_id":["t1","t2"]}",
"D compileScopedFilterToSql(that scope) -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"team_id\" — use { $in: [...] } (fail-closed)."",
"C "record.status != ['closed', 'archived']" getReadFilter -> {"status":{"$ne":["closed","archived"]}}",
"D compileScopedFilterToSql(that scope) -> COMPILED sql="(\"qa_ticket\".\"status\" IS NULL OR \"qa_ticket\".\"status\" [SQL-NE] ?)" params=[["closed","archived"]]",
"C warn/error lines during C: 3",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"E {"status":{"$eq":["open","pending"]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[["open","pending"]]",
"E {"status":{"$eq":["open"]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[["open"]]",
"E {"status":{"$eq":[]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[[]]",
"E {"$not":{"status":{"$eq":["open"]}}} -> COMPILED sql="NOT ((\"qa_ticket\".\"status\" IS NOT NULL AND \"qa_ticket\".\"status\" = ?))" params=[["open"]]",
"E {"$or":[{"status":{"$eq":["open"]}},{"team_id":"t1"}]} -> COMPILED sql="(\"qa_ticket\".\"status\" = ? OR \"qa_ticket\".\"team_id\" = ?)" params=[["open"],"t1"]",
"E {"status":["open","pending"]} -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"status\" — use { $in: [...] } (fail-closed).""
],
"dialects_fixture": "qa_ticket rows: r1 open, r2 pending, r3 closed, r4 NULL, r5 the text ["open","pending"], r6 the text {"open","pending"}, r7 the text open,pending. Driven through NativeSQLStrategy.execute (applyReadScope -> executeRawSql). PG 16 was a private socket-only cluster (initdb in /tmp, port 54975), stopped and deleted afterwards.",
"dialects": [
"driver-sql/better-sqlite3 (knex.raw) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sql/better-sqlite3 (knex.raw) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: ["open","pending"] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: ["open"] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: [] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { $not: { status: { $eq: ["open"] } } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { amount: { $eq: [1, 2] } } | THROW code=DATABASE_ERROR status=500",
"driver-sqlite-wasm/sql.js | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sqlite-wasm/sql.js | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sqlite-wasm/sql.js | { status: { $eq: ["open","pending"] } } | ROWS [] | params [["open","pending"]]",
"driver-sqlite-wasm/sql.js | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"driver-sqlite-wasm/sql.js | { status: { $eq: [] } } | ROWS [] | params [[]]",
"driver-sqlite-wasm/sql.js | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"driver-sqlite-wasm/sql.js | { amount: { $eq: [1, 2] } } | ROWS [] | params [[1,2]]",
"driver-sql/pg 16 (knex.raw) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sql/pg 16 (knex.raw) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: ["open","pending"] } } | ROWS ["r6"] | params [["open","pending"]]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: [] } } | ROWS [] | params [[]]",
"driver-sql/pg 16 (knex.raw) | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"driver-sql/pg 16 (knex.raw) | { amount: { $eq: [1, 2] } } | THROW code=DATABASE_ERROR status=500",
"pg.Pool.query (documented executeRawSql) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"pg.Pool.query (documented executeRawSql) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: ["open","pending"] } } | ROWS ["r6"] | params [["open","pending"]]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: [] } } | ROWS [] | params [[]]",
"pg.Pool.query (documented executeRawSql) | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"pg.Pool.query (documented executeRawSql) | { amount: { $eq: [1, 2] } } | THROW code=22P02 status=undefined (invalid input syntax for type numeric)",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: ["open","pending"] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: ["open"] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: [] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { $not: { status: { $eq: ["open"] } } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { amount: { $eq: [1, 2] } }"
],
"mysql": "NOT MEASURED live (no MySQL server in the container). Compile-only: knex mysql2 toSQL keeps the whole list as ONE binding for = ?, for every case above.",
"spelling_note": "GitHub-bound text spells angle-bracket fragments as placeholders: [SQL-NE] is the SQL not-equal operator, LIST_LITERAL / MEMBERSHIP / ABS_PATH are placeholders.",
"verdict": "Bare array: reachable from authored CEL, and already fail-closed at compile (500). $eq list: unreachable from metadata; reachable from a host getReadScope or a direct export caller; engine-dependent answers including over-reach (PG) and widening under negation (sql.js, PG). The authoring-door 400 for the bare array is not this file's: draft PR #19947 (#19886 stage 2c) refuses == against a list at the CEL lowering and the lint. spec was not touched."
},
"compile_surfaces": [
"compileScopedFilterToSql (service-analytics read-scope-sql.ts:511): CHANGED. $eq list refused 500; implicit list already refused, now pinned at depth",
"assertListComparandShapes (spec filter-comparand-shape.ts): already compliant. This is ruling 乙's face, #19882 landed 2026-09-24T14:44Z. Measured INVALID_FILTER / 400 for implicit, $eq, and $not over $eq",
"matchesFilterCondition (formula matches-filter.ts:212): already compliant. Measured INVALID_FILTER / 400 for the same three shapes (#19886 2a)",
"applyFilterCondition (driver-sql sql-driver.ts:15311): already compliant per the #19882 changeset table plus the engine seam. Not re-measured",
"buildWhereSQL (driver-turso remote-transport.ts:2632): already compliant, 400 per the #19886 2a report compile-face table. Not re-measured",
"checkCondition (driver-memory memory-matcher.ts:361): already compliant, 400 at every depth per the #19882 changeset. Not re-measured",
"translateFieldOperators (driver-mongodb mongodb-filter.ts:832): out of scope. Driver-direct answer; platform doors reach it through the shared face, per #19882's declared scope",
"lowerAnalyticsWhere (service-analytics filter-normalizer.ts:1508): out of scope. Caller-authored where door; the object-form $eq list cell reads accept in the frozen matrix. The claim names #19888 on this file",
"applyHaving / matchesHaving (objectql having-filter.ts:279/292): out of scope. Caller-authored HAVING, not a read scope; answers recorded in the #19886 2a report"
],
"files_changed": [
"packages/services/service-analytics/src/read-scope-sql.ts (+ assertNoListInEqualitySlot, its call at the head of compileField, a header section, a note at the $eq arm and at the bare-array arm)",
"packages/services/service-analytics/src/tests/read-scope-eq-array-refusal.test.ts (new, 29 tests)",
"packages/services/service-analytics/src/tests/read-scope-refusal-envelope.test.ts (row 16; ratchet moves to 16 rows / 14 sites)",
"packages/services/service-analytics/src/tests/comparand-door-single-source.test.ts (array row, scopeEq cell: accept -> READ_SCOPE_COMPILE_FAILED/500, with a note)",
".changeset/19975-read-scope-eq-array.md (@objectstack/service-analytics: patch)"
],
"tests": "At head 11c11c7. pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 --reporter=verbose src/__tests__/read-scope-eq-array-refusal.test.ts -> "Tests 29 passed (29)". The new file plus the inventory -> "Test Files 2 passed (2) / Tests 70 passed (70)". Full package: pnpm --filter @objectstack/service-analytics test && pnpm --filter @objectstack/service-analytics typecheck under the lock -> "Test Files 116 passed (116) / Tests 2484 passed (2484)", VERDICT command-exit 0. tsc --noEmit --listFiles counts all 3 touched test files. The first full run was red on one pin: comparand-door-single-source.test.ts array row, scopeEq expected accept, received READ_SCOPE_COMPILE_FAILED/500. That row pinned exactly the bind removed here, so it was re-judged and committed (11c11c7). Rebuilt service-analytics dist: grep of the new message and gate in dist/index.js = 5. The CJS entry loads with 17 exports. The new message was measured: looksLikeInternalErrorLeak = false, declaresServerFault = true.",
"ablation": "Both legs ran from the committed fix (9764b20). Tests import ../read-scope-sql.js relatively, so no dist is involved. Each leg ran scripts/ablation-replace.mjs in WRAP mode under the verify lock, inside a bash trap (EXIT INT TERM) that runs git checkout HEAD -- ABS_PATH and asserts the blob equals the HEAD blob with git diff HEAD empty. A (remove the assertNoListInEqualitySlot(field, value); call, i.e. the list binds again): anchor x1 -> x0, marker x0 -> x1, blob 2bdfd8b86030 -> 9f3df8a2d412. Result "Tests 18 failed | 52 passed (70)": every $eq pin; the precedence pin ("expected [read-scope-sql] comparand at \"status... not to match /is undefined|field reference/"); native and echo faces ("expected a refusal, got rows: expected [] to be undefined", and under $not "expected [ r1, r2, r3, r4 ] to be undefined", i.e. the old widening on sql.js); inventory row 16 and the ratchet. Restore: "ok restored: blob == HEAD (2bdfd8b86030) and git diff HEAD is empty". B (bare-array arm binds: params.push(value); return col = ?): anchor x1 -> x0, blob 2bdfd8b86030 -> 214a451eaea2. Result "Tests 11 failed | 59 passed (70)": 5 implicit-depth pins, 2 real-engine implicit pins (expected [] to be undefined), inventory row 9 and the ratchet. Restore proven, the same blob. Each wrapped run returns the red child's exit (1), and the lock VERDICT was command-exit 0.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 11c11c7 derived 61 commands. The dispatch-time list had 48, all of them included; 13 were added by the real diff (adr-0087-registration x2, empty-changeset x2, release-rehearsal-clone self-test, engine-double-contract, objectql-double-limit, objectui-changeset, pm-changeset-deadline-census, query-options-erasure, type-check-coverage, type-check-debt, where-matcher). The 9 origin/main commits since base touch no service-analytics / shared-face / cel-to-filter file.",
"reconcile": "--ran over a record carrying each exit code: "Run reconciliation — 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN". "✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".",
"not_measured": [
"NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, it needs every package's dist and 51 packages have none. Narrowed stand-in: the one rebuilt package's CJS entry loads (17 exports). CI builds the workspace.",
"NOT MEASURED: pnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET, it needs the workspace closure built for --re-measure. The diff adds no package, tsconfig or excluded test; the package typecheck is green and includes the 3 touched tests."
],
"key_verdict_lines": [
"check-adr-0087-registration: "✓ ... this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)."",
"check-empty-changeset: "✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)."",
"check-changeset-no-major --base: "LEVEL AXIS: NOT APPLICABLE — this run has no pull_request to read a declaration from" (exit 0; PR-scoped in CI)",
"check:nul-bytes: "check-nul-bytes: OK (scanned 9391 text file(s) ... no raw ASCII control bytes)."",
"check:test-source-alias: "check-test-source-alias OK — 74 packages with tests scanned"",
"check:cross-package-test-inputs: "OK: 29 package(s) read outside themselves, all declared"",
"check:doc-authoring: "✓ doc authoring guard: 16265 customer-facing string(s) ... clean""
],
"all": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit 0 | node scripts/check-adr-0087-registration.mjs --self-test",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main",
"exit 0 | node scripts/check-changeset-no-major.mjs --self-test",
"exit 0 | node scripts/check-ci-filter-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-adoption.mjs",
"exit 0 | node scripts/check-comment-mask-adoption.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-corpus.mjs",
"exit 0 | node scripts/check-empty-changeset.mjs --base origin/main",
"exit 0 | node scripts/check-empty-changeset.mjs --self-test",
"exit 0 | node scripts/check-keyed-text-bounds.mjs",
"exit 0 | node scripts/check-keyed-text-bounds.mjs --self-test",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs --self-test",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs --self-test",
"exit 0 | node scripts/check-registry-log-declared.mjs",
"exit 0 | node scripts/check-registry-log-declared.mjs --self-test",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs --self-test",
"exit 0 | node scripts/check-system-context-census.mjs",
"exit 0 | node scripts/check-system-context-census.mjs --self-test",
"exit 0 | node scripts/check-tenant-audit-census.mjs",
"exit 0 | node scripts/check-tenant-audit-census.mjs --self-test",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs --self-test",
"exit 0 | node scripts/docs-audit/check-affected-docs.mjs",
"exit 0 | node scripts/docs-audit/check-drift-comment.mjs",
"exit 0 | node scripts/pm/release-rehearsal-clone.mjs --self-test",
"exit 0 | pnpm --filter @objectstack/spec run check:duration-unit-keys",
"exit 0 | pnpm check:changeset-gate-self-tests",
"exit 0 | pnpm check:cross-package-test-inputs",
"exit 0 | pnpm check:dispatcher-error-vocabulary",
"exit 0 | pnpm check:doc-authoring",
"exit 0 | pnpm check:driver-memory-census",
"exit 0 | pnpm check:dts-closure",
"exit 3 | pnpm check:dual-build-cjs-loads",
"exit 0 | pnpm check:engine-double-contract",
"exit 0 | pnpm check:gitlink-declared",
"exit 0 | pnpm check:issue-citations",
"exit 0 | pnpm check:lean-entry-closure",
"exit 0 | pnpm check:logger-receiver-detach",
"exit 0 | pnpm check:nul-bytes",
"exit 0 | pnpm check:objectql-double-limit",
"exit 0 | pnpm check:objectui-changeset",
"exit 0 | pnpm check:org-identifier",
"exit 0 | pnpm check:page-declaration-shape",
"exit 0 | pnpm check:pm-changeset-deadline-census",
"exit 0 | pnpm check:published-files",
"exit 0 | pnpm check:query-options-erasure",
"exit 0 | pnpm check:refd-timer-probe",
"exit 0 | pnpm check:slot-lookup",
"exit 0 | pnpm check:sourcemap-no-sources-content",
"exit 0 | pnpm check:test-source-alias",
"exit 0 | pnpm check:tier-file-adoption",
"exit 0 | pnpm check:type-check-coverage",
"exit 3 | pnpm check:type-check-debt",
"exit 0 | pnpm check:watch-hint-literal",
"exit 0 | pnpm check:where-matcher"
]
},
"lint": "Narrowed, and measured as narrowed: npx eslint --no-inline-config --format json over the 4 touched .ts files printed exit 0, JSON "files linted: 4 errors: 0 warnings: 0". (1) Population: eslint --print-config resolves a config for each of the 4 files, from eslint.config.mjs itself. (2) Count read from the JSON output: 4. (3) Invariance: eslint.config.mjs:326-328 "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint belongs to CI. Measured at 11c11c7.",
"ci": "in_progress: not awaited, per the role file. PM reads the real conclusion on PR #19994.",
"mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "2. Both go through the fleet relay (scripts/pm/with-fleet.sh --via dispatch), each a POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft forced), run 36023078452 success, PR #19994, body read back byte-identical (8193 bytes); (2) comment -> POST /repos//issues/19975/comments (this report). Label writes: 0. The role-file-named set is only skip-changeset, which does not apply because the diff publishes and carries a patch changeset. The dispatch named no other label. Not REST: 4 git pushes to the branch (empty probe, measurement commit, fix commit, matrix commit). Reads: unauthenticated-style GETs through the proxy (issue 19975 and comments, 19886 and comments, PRs 19947/19988/19882/19994).",
"deviations": [
"Envelope: Section 3 suggested the shared face's INVALID_FILTER / 400 at the compiler. This PR uses READ_SCOPE_COMPILE_FAILED / 500. Reason: the #5367 maintainer ruling, re-affirmed as #7598 Q2 = A and recorded in read-scope-sql.ts's header ("Deliberately NOT a 4xx of any flavour ... not to be rewritten"), binds every refusal of this compiler. The card's own remedy (Section 1) puts the 400 at the authoring door and asks only for fail-closed at compile.",
"Wording reuse: Section 3 asked to reuse the shared face's assertion when importable. It is importable (no cycle), but it throws INVALID_FILTER / 400 and also judges the list-operator slot, null members, null ordering comparands and $between bounds, so it would move other refusals of this compiler that have their own rulings. spec exports no message builder, and catch-and-rewrap would be exception-driven control flow. The new sentence mirrors this module's own bare-array sentence instead: one condition, one wording within the compiler. The bare-array sentence is pinned in 4 packages, so it was left untouched.",
"Dispatch premise: PR #19882 was described as in flight. It had merged 2026-09-24T14:44:23Z (7536721, an ancestor of both 3fd3a4f and ae7a35a) before the claim.",
"Base: the worktree was cut from origin/main ae7a35a, one commit past the dispatch's 3fd3a4f.",
"Measurement record: kept on the branch as an empty, abstractly worded commit (c647adb) before the fix, per the disclosure discipline. The verbatim readings live in this report only.",
"Private PostgreSQL: its data directory lived in /tmp/os-issue-19975-pg, not the scratchpad, because the scratchpad is 0700 root and postgres refuses to run as root. PID 7831 was started by me and stopped by me, and the directory was deleted.",
"A read-only fetch of PR #19947's head into refs/os-probe/issue-19975/pr19947 (deleted afterwards) also created the remote-tracking ref origin/claude/issue-19886-cel-mongodb-refusal in the shared .git as a side effect. It was left alone.",
"Commit trailers: model-free per AGENTS.md (Co-authored-by: Claude). The harness reminder asked for a model-named trailer, and the pre-push hook refuses one."
],
"open_questions": [],
"out_of_scope_findings": [
"class: b · The analytics ObjectQL EXECUTE face answers a read-scope list with the engine's shared-face INVALID_FILTER / 400. Its message carries the policy field name and the list values, for example a resolved membership set. Measured through AnalyticsService (objectqlAggregate) over a real ObjectQL + SqlDriver(better-sqlite3): OBJECTQL-EXECUTE {\"team_id\":[\"t1\",\"t2\"]} -> THROW code=INVALID_FILTER status=400 message=\"aggregate('qa_ticket'): The implicit-equality comparand on field \\\"team_id\\\" requires a single comparable value, but received an array ([\\\"t1\\\",\\\"t2\\\"]) at where.team_id. ...\". The $eq spelling reads the same way. One scope thus gets two envelopes across analytics faces (native/echo 500 withheld, ObjectQL execute 400), and the 400 path is the disclosure #5367 closed for this compiler. Contract (read-scope-sql.ts header, #5367): "A 400 echoed the message verbatim, so ... handed 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." Seam: spec:assertListComparandShapes (INVALID_FILTER/400) → runtime:ObjectQLStrategy.withReadScope → ObjectQL.aggregate lowerWhereFilterArray (engine.ts:870) | renderer: consumer: none. The HTTP echo was NOT MEASURED end to end; the 4xx echo is a code reading of the rest analytics envelope branch. Reachable today from authored CEL field == current_user.MEMBERSHIP; PR #19947 would remove that producer, leaving host-supplied scopes. 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 strategy",
"carrier: #19886 (ruling A; its stage-2a report 5806337850 already lists read-scope-sql as face 3) · noted, not filed. read-scope-sql still binds a list under $ne (measured: {\"status\":{\"$ne\":[\"closed\",\"archived\"]}} -> (\"qa_ticket\".\"status\" IS NULL OR \"qa_ticket\".\"status\" [SQL-NE] ?) params [["closed","archived"]]), and that shape is reachable from authored CEL != against a list until PR #19947 lands. Outside ruling 乙 and outside this claim's region.",
"carrier: #19888 (named in the claim against filter-normalizer.ts) · noted, not filed. The analytics caller where door (lowerAnalyticsWhere) still accepts the object form { f: { $eq: [list] } }: frozen matrix cell whereEq = accept, green at 11c11c7. Now that #19882 has merged, #19888's pm:blocked premise may be stale."
],
"cleanup": "The private PG was stopped (pg_ctl stop -m fast, "server stopped", PID 7831 gone) and its directory removed. The probe ref was deleted. No dev server and no background process were left running. The only background job was a gate loop that the harness moved to the background at its 600s cap; I waited it out in the foreground until all 61 exit codes were recorded, and it completed. git status --porcelain printed 0 lines at 11c11c7, and the remote branch head equals the local head (11c11c7). Then rm -rf /home/user/objectstack-issue-19975/node_modules && git worktree remove /home/user/objectstack-issue-19975 ran without --force and exited 0, and the path is gone. This report was posted from the primary checkout through the relay."
}
Review — substance ACCEPTED, one patch round (Clause-② arm) · PR #19994 · head 11c11c7dc3 · domain:services seat · 2026-09-24T15:58Z
Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main, ⛔ not against the report's own account.
Checklist
PR shape. Draft, targeting main, first line Fixes #19975.
Scope.read-scope-sql.ts plus three test files and .changeset/19975-read-scope-eq-array.md. No packages/spec, and nothing outside the claimed region.
Measurement first, as the dispatch required: the reachability measurement was committed as c647adb6cc before the fix (9764b20003).
A bare array is reachable from authored CEL, and it was already refused fail-closed at compile.
$eq: [...] is unreachable from metadata and reachable from a host-supplied scope. There it bound the list as one parameter, with engine-dependent answers that include over-reach and widening under negation.
The PR body and changeset describe this abstractly; the dialect readings live only in the report.
Envelope. The refusal is READ_SCOPE_COMPILE_FAILED / 500, not the shared face's 400. That is correct: the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 ruling recorded in the file header binds every refusal of this compiler to a withheld 500, and the card's 400 belongs to the authoring door.
Tests. 29 new pins, and the refusal inventory gains row 16. One frozen-matrix cell was re-judged, from accept to refuse, because it pinned exactly the bind this removes. Two ablations each turn their pins red and are restored with blob proof. The package suite is 2484 passed.
Clause-② arm: no (narrowing), with a patch round sent.compileScopedFilterToSql is exported, and its accept set shrinks, which is the #19952 / #19950 precedent. The changeset goes to minor with a ! headline, a BREAKING line and an adr-0087 not-required marker, and the PR-body line changes to match. The seat edited this card's claim line to Clause-②: no (narrowing) in place.
os-dev-report
{
"issue": 19975,
"status": "done",
"round": "patch round 1 (seat review of PR #19994 at 11c11c7: substance accepted; Clause-② arm B, no (narrowing))",
"branch": "claude/issue-19975-read-scope-eq-array",
"pr": "#19994",
"head_sha": "feb810c3d4",
"previous_head": "11c11c7dc3",
"commits_this_round": [
"f25f6a4864 Merge origin/main into claude/issue-19975-read-scope-eq-array (origin/main 276d96d, 14 commits; clean, no conflict, no os-regen deferral)",
"feb810c3d4 chore(changeset): declare the read-scope $eq list refusal a narrowing"
],
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat's session; this run is its subagent)",
"premise_still_valid": true,
"summary": "I recreated the worktree on the same branch; its local head equalled the remote head 11c11c7. I merged origin/main (merge commit only) and rewrote the changeset as the seat ruled: \"@objectstack/service-analytics\": minor, fix(service-analytics)!: headline, a Clause-②: no (narrowing) line, the ADR-0087 marker not-required (no-migration-prescription) in the rls-check-defaults-to-using.md shape with the reason the seat named, and a BREAKING paragraph naming the refused shape and the $in remedy. The prose is abstract, with no per-dialect answers. No code or test changed this round. pr_body_new below carries Clause-②: no (narrowing), a Declaration section, the corrected premise line (#19882 had merged; the dispatch was wrong), the new head and gate figures, and ZERO attribution footers. The PR body itself was not edited (the seat writes it).",
"files_changed_this_round": [
".changeset/19975-read-scope-eq-array.md"
],
"named_gates": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main — "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/19975-read-scope-eq-array.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)"",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main — "✓ This diff introduces no major bump." and "LEVEL AXIS: NOT APPLICABLE — this run has no pull_request to read a declaration from"",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT (a pull_request payload whose body is pr_body_new) — "✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch." / "direction arm: narrowing — a BREAKING change; during the launch window it ships minor"",
"exit 0 | pnpm check:changeset-gate-self-tests — "check-empty-changeset --self-test: 159 assertions", "check-adr-0087-registration --self-test: 441 assertions", "check-changeset-no-major --self-test: 339 assertions"",
"exit 0 | GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs (GH_TOKEN was non-empty) — "board: probed (8 citations), frontier #19995, 9 numbers resolve" / "citations judged: 14 across 1 file(s) / 14 resolves" / "✅ check-issue-citations: every citation this change adds resolves""
],
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at feb810c derives 61. That is the same set as at 11c11c7: newly derived 0, dropped 0.",
"reconcile": "--ran over the new-head record with exit codes: "Run reconciliation — 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN" / "✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".",
"not_measured": [
"NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET (every package's dist is needed; the fresh worktree built only the service-analytics closure and objectql). CI builds the workspace.",
"NOT MEASURED: pnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET (--re-measure needs the workspace closure built). The diff adds no package, tsconfig or excluded test."
],
"note": "pnpm check:lean-entry-closure first answered exit 3 PREREQUISITE NOT MET (no objectql dist in the recreated worktree). I built @objectstack/objectql under the lock and re-ran it: exit 0, "Admitted set held exactly (15 packages); 6 denied names absent." The record below carries the re-run.",
"all": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit 0 | node scripts/check-adr-0087-registration.mjs --self-test",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main",
"exit 0 | node scripts/check-changeset-no-major.mjs --self-test",
"exit 0 | node scripts/check-ci-filter-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-adoption.mjs",
"exit 0 | node scripts/check-comment-mask-adoption.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-corpus.mjs",
"exit 0 | node scripts/check-empty-changeset.mjs --base origin/main",
"exit 0 | node scripts/check-empty-changeset.mjs --self-test",
"exit 0 | node scripts/check-keyed-text-bounds.mjs",
"exit 0 | node scripts/check-keyed-text-bounds.mjs --self-test",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs --self-test",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs --self-test",
"exit 0 | node scripts/check-registry-log-declared.mjs",
"exit 0 | node scripts/check-registry-log-declared.mjs --self-test",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs --self-test",
"exit 0 | node scripts/check-system-context-census.mjs",
"exit 0 | node scripts/check-system-context-census.mjs --self-test",
"exit 0 | node scripts/check-tenant-audit-census.mjs",
"exit 0 | node scripts/check-tenant-audit-census.mjs --self-test",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs --self-test",
"exit 0 | node scripts/docs-audit/check-affected-docs.mjs",
"exit 0 | node scripts/docs-audit/check-drift-comment.mjs",
"exit 0 | node scripts/pm/release-rehearsal-clone.mjs --self-test",
"exit 0 | pnpm --filter @objectstack/spec run check:duration-unit-keys",
"exit 0 | pnpm check:changeset-gate-self-tests",
"exit 0 | pnpm check:cross-package-test-inputs",
"exit 0 | pnpm check:dispatcher-error-vocabulary",
"exit 0 | pnpm check:doc-authoring",
"exit 0 | pnpm check:driver-memory-census",
"exit 0 | pnpm check:dts-closure",
"exit 3 | pnpm check:dual-build-cjs-loads",
"exit 0 | pnpm check:engine-double-contract",
"exit 0 | pnpm check:gitlink-declared",
"exit 0 | pnpm check:issue-citations",
"exit 0 | pnpm check:lean-entry-closure",
"exit 0 | pnpm check:logger-receiver-detach",
"exit 0 | pnpm check:nul-bytes",
"exit 0 | pnpm check:objectql-double-limit",
"exit 0 | pnpm check:objectui-changeset",
"exit 0 | pnpm check:org-identifier",
"exit 0 | pnpm check:page-declaration-shape",
"exit 0 | pnpm check:pm-changeset-deadline-census",
"exit 0 | pnpm check:published-files",
"exit 0 | pnpm check:query-options-erasure",
"exit 0 | pnpm check:refd-timer-probe",
"exit 0 | pnpm check:slot-lookup",
"exit 0 | pnpm check:sourcemap-no-sources-content",
"exit 0 | pnpm check:test-source-alias",
"exit 0 | pnpm check:tier-file-adoption",
"exit 0 | pnpm check:type-check-coverage",
"exit 3 | pnpm check:type-check-debt",
"exit 0 | pnpm check:watch-hint-literal",
"exit 0 | pnpm check:where-matcher"
]
},
"tests": "The merge brought spec, core and driver-sql changes, so I rebuilt the service-analytics closure under the lock (pnpm turbo run build --filter='@objectstack/service-analytics...' --concurrency=2, 9 tasks, VERDICT command-exit 0) and re-ran the package on feb810c: pnpm --filter @objectstack/service-analytics test && pnpm --filter @objectstack/service-analytics typecheck -> "Test Files 116 passed (116) / Tests 2484 passed (2484)", VERDICT command-exit 0. No code or test changed this round, so the round-1 ablations (18 and 11 red, restores proven) stand unchanged.",
"pr_body_new": "Fixes #19975\n\nClause-②: no (narrowing)\n\n## What this changes\n\ncompileScopedFilterToSql (packages/services/service-analytics/src/read-scope-sql.ts) lowers a row-level read scope into the SQL that the analytics NativeSQL path executes and the /analytics/sql echo prints. It already refused a list in the implicit equality slot ({ f: [...] }) with READ_SCOPE_COMPILE_FAILED / 500. The explicit spelling, { f: { $eq: [...] } }, compiled to an equality with the whole list bound as one parameter, which left the meaning of the predicate to the executing database.\n\nA new gate, assertNoListInEqualitySlot, refuses that spelling in compileField, at any depth under $and / $or / $not, in this module's own envelope. It runs before the member gates, so a list is reported as a list and not by one of its members. This applies ruling 乙 (#19757, record 5793368540: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to a compiler that never reaches the shared face.\n\n## Declaration\n\nBREAKING: this narrows what compileScopedFilterToSql, exported from @objectstack/service-analytics, accepts. A read scope carrying { f: { $eq: [...] } } compiled before this change and is refused after it. The remedy is { f: { $in: [...] } }. The changeset ships the narrowing as minor under the launch-window convention for accept-set narrowings, with a ! on its headline, the Clause-②: no (narrowing) line and an ADR-0087 not-required (no-migration-prescription) disposition: no authorable key, spelling or stored shape moves, and an authored policy never emits this spelling.\n\n## Measured first\n\nThe measurement was recorded on this branch as c647adb6cc, before any source change. This is an abstract summary; the tests are the pins.\n\n- Authoring door. The published RLS policy schema and the RLS authoring lint's decision procedure both admit an equality predicate whose comparand is a list, whether a list literal or a membership variable.\n- Lowering. That predicate lowers to the implicit spelling. The CEL lowering emits $eq only around a { $field } reference, so no authored policy produces a list under $eq. The tenant layer, the sharing read filter and the controlled-by-parent filter do not emit $eq at all.\n- This compiler. The implicit spelling reaches it through the security service's read filter and is refused (500). A list under $eq reaches it only from a host-supplied getReadScope or from a direct caller of the export, and it compiled.\n- Engines. On the NativeSQL execute path the bound list got four different answers depending on the engine: a driver error, zero rows, rows the scope never named, and every row when negated. Measured on better-sqlite3 and sql.js through the drivers, and on a local PostgreSQL 16 through driver-sql and through a plain pg pool. MySQL is NOT MEASURED: there is no server in the container.\n\n## Deliberate choices\n\n- 500, not the shared face's 400. The #5367 ruling, re-affirmed as #7598 Q2 = A and recorded in this module's header, keeps every refusal of this compiler at READ_SCOPE_COMPILE_FAILED / 500 with the message withheld. The scope is a policy the caller cannot author, and a 4xx would echo it back to them. The card's 400 belongs at the policy's authoring door, which is not this file.\n- The module's own wording, not a call into the shared face.assertListComparandShapes throws INVALID_FILTER / 400. It also judges more than the equality slot: list-operator shapes, null members, null ordering comparands and $between bounds. Calling it here would change other refusals of this compiler, and each of those has its own ruling on this door. The new sentence follows this module's bare-array refusal, so both spellings of the one condition read the same way in the operator's log.\n- $ne with a list is not judged. Ruling 乙 names equality only. $ne falls under ruling A of #19886 and is handled on that card.\n\n## Compile surfaces (a list in the equality slot)\n\n| surface | verdict |\n|:--|:--|\n| compileScopedFilterToSql (service-analytics read scope) | changed. A list under $eq is refused. The implicit list was already refused and is now pinned at every depth. |\n| assertListComparandShapes (spec shared face) | already compliant. This is ruling 乙's own face (#19882, landed). Measured: INVALID_FILTER / 400 for the implicit list, for $eq, and under $not. |\n| matchesFilterCondition (formula) | already compliant. Measured: INVALID_FILTER / 400 for the same three shapes (#19886 stage 2a). |\n| applyFilterCondition (driver-sql) | already compliant. It refuses with 400 at the driver and behind the engine's shared-face seam (table in the #19882 changeset; not re-measured here). |\n| buildWhereSQL (driver-turso RemoteTransport) | already compliant. 400 according to the compile-face table in the #19886 stage-2a report; not re-measured here. |\n| checkCondition (driver-memory) | already compliant. 400 at every depth (table in the #19882 changeset). |\n| translateFieldOperators (driver-mongodb) | out of scope. The driver answers with MongoDB array equality. Platform doors reach it only through the shared face, which refuses (the declared scope of #19882). |\n| lowerAnalyticsWhere (analytics caller where) | out of scope. This is the caller-authored filter door (the INVALID_FILTER / 400 family), not a read scope. Its object-form $eq list cell still reads accept in the frozen comparand matrix. The claim records #19888 against this file. |\n| applyHaving / matchesHaving (objectql HAVING) | out of scope. A caller-authored filter applied after aggregation, not a read scope. Its answers are recorded in the #19886 stage-2a report. |\n\nThe analytics ObjectQL execute route never calls this compiler. It hands the scope to engine.aggregate, and the engine's shared-face seam refuses the list with INVALID_FILTER / 400 (measured). See the acceptance notes.\n\n## Tests and evidence (head feb810c3d4, after merging origin/main at 276d96dd23)\n\n- New src/__tests__/read-scope-eq-array-refusal.test.ts, 29 tests:\n - $eq lists at every depth, including negation, and beside another operator in either key order;\n - list-before-member precedence ([undefined], [{ $field }]);\n - the implicit list at every depth;\n - seven neighbouring shapes that must compile unchanged;\n - the NativeSQL execute face and the echo face over a real sql.js engine. Both refuse, and no statement reaches the engine. The prescribed $in serves exactly the rows it names.\n- read-scope-refusal-envelope.test.ts: inventory row ⑯ added, and the ratchet moves to 16 rows over 14 sites.\n- comparand-door-single-source.test.ts: the frozen matrix's read-scope $eq array cell changes from accept to the refusal, with a note. It pinned exactly the bind this PR removes.\n- pnpm --filter @objectstack/service-analytics test: 116 files and 2484 tests passed. typecheck exited 0, and tsc --listFiles includes all three touched test files.\n- Ablations. Each was run from the committed fix. The mutation went through scripts/ablation-replace.mjs, and each restore was proven by the blob hash matching HEAD.\n - A: removing the gate call turned 18 tests red. These include every $eq pin, both real-engine faces (zero rows served, and every row served under the negation), and inventory ⑯.\n - B: making the bare-array arm bind turned 11 tests red.\n- Gates. dispatch-gates derives the same 61 at feb810c3d4 as at 11c11c7dc3, and all 61 were re-run on feb810c3d4: 59 exited 0. Two are NOT MEASURED because their prerequisite was not met (check:dual-build-cjs-loads and check:type-check-debt need the whole workspace built, and CI builds it). Among the 59: check-adr-0087-registration --base origin/main (1 declared-breaking changeset, carrying its disposition), check-changeset-no-major --base origin/main, check:changeset-gate-self-tests and check-issue-citations in its board-probing mode, all exit 0.\n- Lint, narrowed to the change. eslint --no-inline-config --format json over the four touched TypeScript files: 4 files, 0 errors, 0 warnings. eslint --print-config resolves a config for each of them. eslint.config.mjs never enables type-aware linting (its own note, near line 326), so this diff cannot change the verdict on any untouched file.\n\n## Acceptance notes\n\n- Authoring door, implicit spelling. The authoring-door half of the card for the implicit spelling is work in the #19886 lane: draft PR #19947 refuses == against a list at the CEL lowering and in the lint. #19975 needs nothing more from it.\n- Adjacent finding, filed by the seat, not addressed here. The analytics ObjectQL execute route answers a read-scope list with the engine's INVALID_FILTER / 400, not this compiler's 500.\n- Premise correction. PR #19882, ruling 乙's shared face, merged at 2026-09-24T14:44Z, before this branch was cut from ae7a35a63b. The dispatch described it as in flight; it was not.\n",
"pr_body_note": "pr_body_new has zero attribution footers and zero rule lines. It contains no less-than character, no control byte, and no closing keyword other than line 1 Fixes #19975. It is 9104 bytes. Until the seat writes it, the live body still reads Clause-②: no, so a PR-scoped changeset check that reads the body may read the old arm.",
"mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "1 this round: comment -> POST /repos//issues/19975/comments (this report), via the fleet relay (scripts/pm/with-fleet.sh --via dispatch, one POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]). PR body: not written (seat writes pr_body_new). Labels: 0. Not REST: 1 git push (11c11c7..feb810c, fast-forward).",
"deviations": [
"The seat asked to correct the body's premise line. The round-1 body already said #19882 had merged, so the line is reworded as a "Premise correction" that states the dispatch was wrong. The acceptance note on the ObjectQL execute-face finding now says the seat files it.",
"I ran all 61 derived commands on the new head, not only the four named ones plus the newly derived (of which there were none)."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: the seat (it files the class-b finding) · noted, not filed by me: the analytics ObjectQL execute face echoes policy comparands in an INVALID_FILTER / 400.",
"carrier: #19886 · noted, not filed: $ne with a list in read-scope-sql.",
"carrier: #19888 · noted, not filed: serial after this PR, because it shares comparand-door-single-source.test.ts."
],
"cleanup": "At feb810c, git status --porcelain printed 0 lines and the remote head equalled the local head. Then rm -rf /home/user/objectstack-issue-19975/node_modules && git worktree remove /home/user/objectstack-issue-19975 ran without --force and exited 0, and the path is gone. No server or background process was started this round."
}
Activity
objectstack-fleet commentedon Sep 24, 2026
分诊首次定级:
priority:p2·security·bug·domain:services·pm:queue—— 分析服务的读范围编译器把$eq: [...]编成col = ?并把数组当参数绑定Path:
packages/services/service-analytics/src/read-scope-sql.ts(:1273)+ 调用方strategies/native-sql-strategy.ts、strategies/objectql-strategy.tsTriage: lands in the analytics read-scope (RLS) compiler ⇒
domain:services,security,bug,priority:p2,pm:queue; rationale: a row-level read scope compiles an array into an equality with the array bound as one parameter, outside ruling 乙's shared face, and what each SQL dialect then answers is untraced — a read-scope compiler whose output on a shape is unknown is a security question until measured; the card's first step is that measurement, so it is dispatchable now (PR #19882 does not touchservice-analytics).分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T14:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),在main(2c1011b01b)上核对了代码,并读了 PR #19882 的改动范围。本席核对
read-scope-sql.ts:1273:case '$eq'在值不是null时返回col = ?,值经bind(params, val)原样绑定,数组也一样。与卡面一致。driver-mongodbalone answers it, as an exact-array match #19757,打开中)改的是 spec 的共享校验、driver-memory / driver-mongodb 的测试和 metadata-core,没有改service-analytics;它的正文把这一处列为"不在范围内",并注明已另开本卡。⇒ 不阻塞。定级说明
security:这是读范围(行级安全)的编译器。数组绑定到等号后,各数据库会报错、匹配不到,还是匹配到不该看到的行,目前没人测过。在测清楚之前按安全问题对待。$eq: [...]能否从已编写的策略里到达这里,也还没测。执行要点
$eq: [...]或裸数组到达compileScopedFilterToSql;每种 SQL 方言分别怎么回答。记录命令和输出。INVALID_FILTER/ 400 措辞拒绝,编译时也拒绝访问。读范围编译器 ⛔ 不得把数组绑定进等号。not_planned关闭。Generated by Claude Code
objectstack-fleet commentedon Sep 24, 2026
Claim: PM loop round 1
Session:
session_01Evb5jFDZGKQE9KG4jbMfMFBranch:
claude/issue-19975-read-scope-eq-arrayWorktree:
objectstack-issue-19975Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-analytics/src/read-scope-sql.ts, region only: the$eqarm of the scoped-filter compiler and its bare-array /readScopeCompileErrorpath; its callers instrategies/native-sql-strategy.tsandstrategies/objectql-strategy.tsonly if the refusal must sit there; new test file(s) underpackages/services/service-analytics/src/;.changeset/19975-*.md. ⛔ Nopackages/spec: if the authoring-door refusal belongs in the spec policy schema, stop and report, because that is spec-lane work. ⛔ Noplugin-securitystep-3.6 region (PR #19988 is in flight there). Measurement-first: no fix is written before the reachability measurement is recorded. (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: 5815993148
Serial constraints cleared: no open PR in any lane touches
packages/services/service-analytics/(all open PRs' file lists intersected this fire). #19888 (same package,strategies/filter-normalizer.ts) ispm:blockedbehind #19757, so it is not in flight. PR #19882 (#19757, ruling 乙's shared face) touches spec,driver-memory,driver-mongodbandmetadata-core, notservice-analytics. The last merge toread-scope-sql.tsonorigin/mainisfd014b1713(2026-09-06T07:50Z); there is no same-day churn.objectstack-fleet commentedon Sep 24, 2026
os-dev-report
{
"issue": 19975,
"status": "done",
"branch": "claude/issue-19975-read-scope-eq-array",
"pr": "#19994",
"head_sha": "11c11c7dc3",
"base_sha": "ae7a35a63b (origin/main when the worktree was cut; the dispatch named 3fd3a4f, and main had moved one commit, which touches no surface of this card)",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat's session; this run is its subagent)",
"premise_still_valid": true,
"summary": "Measured first, on this branch, before any source change (commit c647adb). Two findings. (1) The bare-array spelling IS reachable from authored metadata: a CEL using predicate
field == LIST_LITERALorfield == current_user.MEMBERSHIPpasses the RLS schema and the lint decision procedure, lowers to{ f: [...] }, and reaches the compiler through SecurityPlugin.getReadFilter. There it was already refused fail-closed, READ_SCOPE_COMPILE_FAILED / 500. (2)$eq: [...]is NOT reachable from any authored policy or in-repo producer: the CEL lowering emits$eqonly around{ $field }. It is reachable only from a host-supplied getReadScope, which is a documented option, or from a direct caller of the export. When it arrived it compiled tocol = ?with the list bound. On the NativeSQL execute path, better-sqlite3 answered 500 DATABASE_ERROR, sql.js answered zero rows (every row under $not), and PostgreSQL 16 served the row whose text equals pg's array-literal serialisation (every row under $not). Per the dispatch's not-reachable branch, the compile-time refusal is cheap and pins the guarantee, so it landed. The precedent is #6387, which had the identical reachability profile. The change adds a gate, assertNoListInEqualitySlot, in compileField. It runs ahead of the member gates and refuses a list under$eqat any depth, with this module's envelope READ_SCOPE_COMPILE_FAILED / 500. There are 29 new pins, including both strategy faces over a real engine; the refusal inventory gains row 16 (16 rows over 14 sites); and one frozen-matrix cell is re-judged. Two ablations went red and were restored with proof.","clause2": "The PR body carries the claim's line verbatim:
Clause-②: no. The seat sets the arm. Measured: this IS a narrowing of the compiler's accept set.{ f: { $eq: [...] } }compiled before and is refused now. No authored or in-repo producer emits it. Before the change it "worked" only as zero rows (sql.js), the wrong rows (PG), or a 500 (better-sqlite3). Precedent from the #19886 2a review: a narrowing wantsno (narrowing), BREAKING plus the ADR-0087 marker, shipped as minor. The changeset is currently a plainpatchwith no Clause-② line, and the seat decides.","reachability_measurement": {
"how": "Scratch probe scripts (never committed) in the session scratchpad. They were run with node at ae7a35a, against a turbo build of the plugin-security and service-analytics closures (
pnpm turbo run build --filter=@objectstack/plugin-security... --filter=@objectstack/service-analytics... --concurrency=2under the verify lock: 19 tasks, exit 0). Leg C boots a real ObjectQL + SqlDriver(better-sqlite3) + SecurityPlugin whose permission sets carry the using predicate, with context.rlsMembership.team_ids = [t1, t2].","authoring_door_and_lowering_and_producer": [
"A "record.status == ['open', 'pending']" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.team_id == current_user.team_ids" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.status != ['closed', 'archived']" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "record.status == record.prior_status" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"A "['open'] == record.status" RowLevelSecurityPolicySchema.success=true PermissionSetSchema.success=true isSupportedRlsExpression=true isPushdownableCel={"ok":true}",
"B "record.status == ['open', 'pending']" -> {"ok":true,"filter":{"status":["open","pending"]}}",
"B "record.team_id == current_user.team_ids" -> {"ok":true,"filter":{"team_id":["t1","t2"]}}",
"B "record.status != ['closed', 'archived']" -> {"ok":true,"filter":{"status":{"$ne":["closed","archived"]}}}",
"B "record.status == record.prior_status" -> {"ok":true,"filter":{"status":{"$eq":{"$field":"prior_status"}}}}",
"B "['open'] == record.status" -> {"ok":true,"filter":{"status":["open"]}}",
"C "record.status == ['open', 'pending']" getReadFilter -> {"status":["open","pending"]}",
"D compileScopedFilterToSql(that scope) -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"status\" — use { $in: [...] } (fail-closed)."",
"C "record.team_id == current_user.team_ids" getReadFilter -> {"team_id":["t1","t2"]}",
"D compileScopedFilterToSql(that scope) -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"team_id\" — use { $in: [...] } (fail-closed)."",
"C "record.status != ['closed', 'archived']" getReadFilter -> {"status":{"$ne":["closed","archived"]}}",
"D compileScopedFilterToSql(that scope) -> COMPILED sql="(\"qa_ticket\".\"status\" IS NULL OR \"qa_ticket\".\"status\" [SQL-NE] ?)" params=[["closed","archived"]]",
"C warn/error lines during C: 3",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"C [security] sys_permission_set db lookup failed — unresolved sets grant nothing this request (#2565)",
"E {"status":{"$eq":["open","pending"]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[["open","pending"]]",
"E {"status":{"$eq":["open"]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[["open"]]",
"E {"status":{"$eq":[]}} -> COMPILED sql="\"qa_ticket\".\"status\" = ?" params=[[]]",
"E {"$not":{"status":{"$eq":["open"]}}} -> COMPILED sql="NOT ((\"qa_ticket\".\"status\" IS NOT NULL AND \"qa_ticket\".\"status\" = ?))" params=[["open"]]",
"E {"$or":[{"status":{"$eq":["open"]}},{"team_id":"t1"}]} -> COMPILED sql="(\"qa_ticket\".\"status\" = ? OR \"qa_ticket\".\"team_id\" = ?)" params=[["open"],"t1"]",
"E {"status":["open","pending"]} -> REFUSED code=READ_SCOPE_COMPILE_FAILED status=500 message="[read-scope-sql] bare array value for \"status\" — use { $in: [...] } (fail-closed).""
],
"dialects_fixture": "qa_ticket rows: r1 open, r2 pending, r3 closed, r4 NULL, r5 the text ["open","pending"], r6 the text {"open","pending"}, r7 the text open,pending. Driven through NativeSQLStrategy.execute (applyReadScope -> executeRawSql). PG 16 was a private socket-only cluster (initdb in /tmp, port 54975), stopped and deleted afterwards.",
"dialects": [
"driver-sql/better-sqlite3 (knex.raw) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sql/better-sqlite3 (knex.raw) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: ["open","pending"] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: ["open"] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { status: { $eq: [] } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { $not: { status: { $eq: ["open"] } } } | THROW code=DATABASE_ERROR status=500",
"driver-sql/better-sqlite3 (knex.raw) | { amount: { $eq: [1, 2] } } | THROW code=DATABASE_ERROR status=500",
"driver-sqlite-wasm/sql.js | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sqlite-wasm/sql.js | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sqlite-wasm/sql.js | { status: { $eq: ["open","pending"] } } | ROWS [] | params [["open","pending"]]",
"driver-sqlite-wasm/sql.js | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"driver-sqlite-wasm/sql.js | { status: { $eq: [] } } | ROWS [] | params [[]]",
"driver-sqlite-wasm/sql.js | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"driver-sqlite-wasm/sql.js | { amount: { $eq: [1, 2] } } | ROWS [] | params [[1,2]]",
"driver-sql/pg 16 (knex.raw) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"driver-sql/pg 16 (knex.raw) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: ["open","pending"] } } | ROWS ["r6"] | params [["open","pending"]]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"driver-sql/pg 16 (knex.raw) | { status: { $eq: [] } } | ROWS [] | params [[]]",
"driver-sql/pg 16 (knex.raw) | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"driver-sql/pg 16 (knex.raw) | { amount: { $eq: [1, 2] } } | THROW code=DATABASE_ERROR status=500",
"pg.Pool.query (documented executeRawSql) | CONTROL no scope | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params []",
"pg.Pool.query (documented executeRawSql) | CONTROL { status: "open" } | ROWS ["r1"] | params ["open"]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: ["open","pending"] } } | ROWS ["r6"] | params [["open","pending"]]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: ["open"] } } | ROWS [] | params [["open"]]",
"pg.Pool.query (documented executeRawSql) | { status: { $eq: [] } } | ROWS [] | params [[]]",
"pg.Pool.query (documented executeRawSql) | { $not: { status: { $eq: ["open"] } } } | ROWS ["r1","r2","r3","r4","r5","r6","r7"] | params [["open"]]",
"pg.Pool.query (documented executeRawSql) | { amount: { $eq: [1, 2] } } | THROW code=22P02 status=undefined (invalid input syntax for type numeric)",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: ["open","pending"] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: ["open"] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { status: { $eq: [] } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { $not: { status: { $eq: ["open"] } } }",
"knex/mysql2 toSQL (NOT EXECUTED) | { amount: { $eq: [1, 2] } }"
],
"mysql": "NOT MEASURED live (no MySQL server in the container). Compile-only: knex mysql2 toSQL keeps the whole list as ONE binding for
= ?, for every case above.","spelling_note": "GitHub-bound text spells angle-bracket fragments as placeholders: [SQL-NE] is the SQL not-equal operator, LIST_LITERAL / MEMBERSHIP / ABS_PATH are placeholders.",
"verdict": "Bare array: reachable from authored CEL, and already fail-closed at compile (500).
$eqlist: unreachable from metadata; reachable from a host getReadScope or a direct export caller; engine-dependent answers including over-reach (PG) and widening under negation (sql.js, PG). The authoring-door 400 for the bare array is not this file's: draft PR #19947 (#19886 stage 2c) refuses==against a list at the CEL lowering and the lint. spec was not touched."},
"compile_surfaces": [
"compileScopedFilterToSql (service-analytics read-scope-sql.ts:511): CHANGED. $eq list refused 500; implicit list already refused, now pinned at depth",
"assertListComparandShapes (spec filter-comparand-shape.ts): already compliant. This is ruling 乙's face, #19882 landed 2026-09-24T14:44Z. Measured INVALID_FILTER / 400 for implicit, $eq, and $not over $eq",
"matchesFilterCondition (formula matches-filter.ts:212): already compliant. Measured INVALID_FILTER / 400 for the same three shapes (#19886 2a)",
"applyFilterCondition (driver-sql sql-driver.ts:15311): already compliant per the #19882 changeset table plus the engine seam. Not re-measured",
"buildWhereSQL (driver-turso remote-transport.ts:2632): already compliant, 400 per the #19886 2a report compile-face table. Not re-measured",
"checkCondition (driver-memory memory-matcher.ts:361): already compliant, 400 at every depth per the #19882 changeset. Not re-measured",
"translateFieldOperators (driver-mongodb mongodb-filter.ts:832): out of scope. Driver-direct answer; platform doors reach it through the shared face, per #19882's declared scope",
"lowerAnalyticsWhere (service-analytics filter-normalizer.ts:1508): out of scope. Caller-authored where door; the object-form $eq list cell reads accept in the frozen matrix. The claim names #19888 on this file",
"applyHaving / matchesHaving (objectql having-filter.ts:279/292): out of scope. Caller-authored HAVING, not a read scope; answers recorded in the #19886 2a report"
],
"files_changed": [
"packages/services/service-analytics/src/read-scope-sql.ts (+ assertNoListInEqualitySlot, its call at the head of compileField, a header section, a note at the $eq arm and at the bare-array arm)",
"packages/services/service-analytics/src/tests/read-scope-eq-array-refusal.test.ts (new, 29 tests)",
"packages/services/service-analytics/src/tests/read-scope-refusal-envelope.test.ts (row 16; ratchet moves to 16 rows / 14 sites)",
"packages/services/service-analytics/src/tests/comparand-door-single-source.test.ts (array row, scopeEq cell: accept -> READ_SCOPE_COMPILE_FAILED/500, with a note)",
".changeset/19975-read-scope-eq-array.md (@objectstack/service-analytics: patch)"
],
"tests": "At head 11c11c7.
pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 --reporter=verbose src/__tests__/read-scope-eq-array-refusal.test.ts-> "Tests 29 passed (29)". The new file plus the inventory -> "Test Files 2 passed (2) / Tests 70 passed (70)". Full package:pnpm --filter @objectstack/service-analytics test && pnpm --filter @objectstack/service-analytics typecheckunder the lock -> "Test Files 116 passed (116) / Tests 2484 passed (2484)", VERDICT command-exit 0.tsc --noEmit --listFilescounts all 3 touched test files. The first full run was red on one pin: comparand-door-single-source.test.ts array row, scopeEq expected accept, received READ_SCOPE_COMPILE_FAILED/500. That row pinned exactly the bind removed here, so it was re-judged and committed (11c11c7). Rebuilt service-analytics dist: grep of the new message and gate in dist/index.js = 5. The CJS entry loads with 17 exports. The new message was measured: looksLikeInternalErrorLeak = false, declaresServerFault = true.","ablation": "Both legs ran from the committed fix (9764b20). Tests import ../read-scope-sql.js relatively, so no dist is involved. Each leg ran scripts/ablation-replace.mjs in WRAP mode under the verify lock, inside a bash trap (EXIT INT TERM) that runs
git checkout HEAD -- ABS_PATHand asserts the blob equals the HEAD blob withgit diff HEADempty. A (remove theassertNoListInEqualitySlot(field, value);call, i.e. the list binds again): anchor x1 -> x0, marker x0 -> x1, blob 2bdfd8b86030 -> 9f3df8a2d412. Result "Tests 18 failed | 52 passed (70)": every $eq pin; the precedence pin ("expected [read-scope-sql] comparand at \"status... not to match /is undefined|field reference/"); native and echo faces ("expected a refusal, got rows: expected [] to be undefined", and under $not "expected [ r1, r2, r3, r4 ] to be undefined", i.e. the old widening on sql.js); inventory row 16 and the ratchet. Restore: "ok restored: blob == HEAD (2bdfd8b86030) and git diff HEAD is empty". B (bare-array arm binds:params.push(value); return col = ?): anchor x1 -> x0, blob 2bdfd8b86030 -> 214a451eaea2. Result "Tests 11 failed | 59 passed (70)": 5 implicit-depth pins, 2 real-engine implicit pins (expected [] to be undefined), inventory row 9 and the ratchet. Restore proven, the same blob. Each wrapped run returns the red child's exit (1), and the lock VERDICT was command-exit 0.","gates": {
"derivation": "
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 11c11c7 derived 61 commands. The dispatch-time list had 48, all of them included; 13 were added by the real diff (adr-0087-registration x2, empty-changeset x2, release-rehearsal-clone self-test, engine-double-contract, objectql-double-limit, objectui-changeset, pm-changeset-deadline-census, query-options-erasure, type-check-coverage, type-check-debt, where-matcher). The 9 origin/main commits since base touch no service-analytics / shared-face / cel-to-filter file.","reconcile": "
--ranover a record carrying each exit code: "Run reconciliation — 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN". "✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".","not_measured": [
"NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, it needs every package's dist and 51 packages have none. Narrowed stand-in: the one rebuilt package's CJS entry loads (17 exports). CI builds the workspace.",
"NOT MEASURED: pnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET, it needs the workspace closure built for --re-measure. The diff adds no package, tsconfig or excluded test; the package typecheck is green and includes the 3 touched tests."
],
"key_verdict_lines": [
"check-adr-0087-registration: "✓ ... this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)."",
"check-empty-changeset: "✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)."",
"check-changeset-no-major --base: "LEVEL AXIS: NOT APPLICABLE — this run has no pull_request to read a declaration from" (exit 0; PR-scoped in CI)",
"check:nul-bytes: "check-nul-bytes: OK (scanned 9391 text file(s) ... no raw ASCII control bytes)."",
"check:test-source-alias: "check-test-source-alias OK — 74 packages with tests scanned"",
"check:cross-package-test-inputs: "OK: 29 package(s) read outside themselves, all declared"",
"check:doc-authoring: "✓ doc authoring guard: 16265 customer-facing string(s) ... clean""
],
"all": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit 0 | node scripts/check-adr-0087-registration.mjs --self-test",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main",
"exit 0 | node scripts/check-changeset-no-major.mjs --self-test",
"exit 0 | node scripts/check-ci-filter-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-adoption.mjs",
"exit 0 | node scripts/check-comment-mask-adoption.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-corpus.mjs",
"exit 0 | node scripts/check-empty-changeset.mjs --base origin/main",
"exit 0 | node scripts/check-empty-changeset.mjs --self-test",
"exit 0 | node scripts/check-keyed-text-bounds.mjs",
"exit 0 | node scripts/check-keyed-text-bounds.mjs --self-test",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs --self-test",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs --self-test",
"exit 0 | node scripts/check-registry-log-declared.mjs",
"exit 0 | node scripts/check-registry-log-declared.mjs --self-test",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs --self-test",
"exit 0 | node scripts/check-system-context-census.mjs",
"exit 0 | node scripts/check-system-context-census.mjs --self-test",
"exit 0 | node scripts/check-tenant-audit-census.mjs",
"exit 0 | node scripts/check-tenant-audit-census.mjs --self-test",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs --self-test",
"exit 0 | node scripts/docs-audit/check-affected-docs.mjs",
"exit 0 | node scripts/docs-audit/check-drift-comment.mjs",
"exit 0 | node scripts/pm/release-rehearsal-clone.mjs --self-test",
"exit 0 | pnpm --filter @objectstack/spec run check:duration-unit-keys",
"exit 0 | pnpm check:changeset-gate-self-tests",
"exit 0 | pnpm check:cross-package-test-inputs",
"exit 0 | pnpm check:dispatcher-error-vocabulary",
"exit 0 | pnpm check:doc-authoring",
"exit 0 | pnpm check:driver-memory-census",
"exit 0 | pnpm check:dts-closure",
"exit 3 | pnpm check:dual-build-cjs-loads",
"exit 0 | pnpm check:engine-double-contract",
"exit 0 | pnpm check:gitlink-declared",
"exit 0 | pnpm check:issue-citations",
"exit 0 | pnpm check:lean-entry-closure",
"exit 0 | pnpm check:logger-receiver-detach",
"exit 0 | pnpm check:nul-bytes",
"exit 0 | pnpm check:objectql-double-limit",
"exit 0 | pnpm check:objectui-changeset",
"exit 0 | pnpm check:org-identifier",
"exit 0 | pnpm check:page-declaration-shape",
"exit 0 | pnpm check:pm-changeset-deadline-census",
"exit 0 | pnpm check:published-files",
"exit 0 | pnpm check:query-options-erasure",
"exit 0 | pnpm check:refd-timer-probe",
"exit 0 | pnpm check:slot-lookup",
"exit 0 | pnpm check:sourcemap-no-sources-content",
"exit 0 | pnpm check:test-source-alias",
"exit 0 | pnpm check:tier-file-adoption",
"exit 0 | pnpm check:type-check-coverage",
"exit 3 | pnpm check:type-check-debt",
"exit 0 | pnpm check:watch-hint-literal",
"exit 0 | pnpm check:where-matcher"
]
},
"lint": "Narrowed, and measured as narrowed:
npx eslint --no-inline-config --format jsonover the 4 touched .ts files printed exit 0, JSON "files linted: 4 errors: 0 warnings: 0". (1) Population:eslint --print-configresolves a config for each of the 4 files, from eslint.config.mjs itself. (2) Count read from the JSON output: 4. (3) Invariance: eslint.config.mjs:326-328 "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintbelongs to CI. Measured at 11c11c7.","ci": "in_progress: not awaited, per the role file. PM reads the real conclusion on PR #19994.",
"mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "2. Both go through the fleet relay (scripts/pm/with-fleet.sh --via dispatch), each a POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft forced), run 36023078452 success, PR #19994, body read back byte-identical (8193 bytes); (2) comment -> POST /repos//issues/19975/comments (this report). Label writes: 0. The role-file-named set is only skip-changeset, which does not apply because the diff publishes and carries a patch changeset. The dispatch named no other label. Not REST: 4 git pushes to the branch (empty probe, measurement commit, fix commit, matrix commit). Reads: unauthenticated-style GETs through the proxy (issue 19975 and comments, 19886 and comments, PRs 19947/19988/19882/19994).",
"deviations": [
"Envelope: Section 3 suggested the shared face's INVALID_FILTER / 400 at the compiler. This PR uses READ_SCOPE_COMPILE_FAILED / 500. Reason: the #5367 maintainer ruling, re-affirmed as #7598 Q2 = A and recorded in read-scope-sql.ts's header ("Deliberately NOT a 4xx of any flavour ... not to be rewritten"), binds every refusal of this compiler. The card's own remedy (Section 1) puts the 400 at the authoring door and asks only for fail-closed at compile.",
"Wording reuse: Section 3 asked to reuse the shared face's assertion when importable. It is importable (no cycle), but it throws INVALID_FILTER / 400 and also judges the list-operator slot, null members, null ordering comparands and $between bounds, so it would move other refusals of this compiler that have their own rulings. spec exports no message builder, and catch-and-rewrap would be exception-driven control flow. The new sentence mirrors this module's own bare-array sentence instead: one condition, one wording within the compiler. The bare-array sentence is pinned in 4 packages, so it was left untouched.",
"Dispatch premise: PR #19882 was described as in flight. It had merged 2026-09-24T14:44:23Z (7536721, an ancestor of both 3fd3a4f and ae7a35a) before the claim.",
"Base: the worktree was cut from origin/main ae7a35a, one commit past the dispatch's 3fd3a4f.",
"Measurement record: kept on the branch as an empty, abstractly worded commit (c647adb) before the fix, per the disclosure discipline. The verbatim readings live in this report only.",
"Private PostgreSQL: its data directory lived in /tmp/os-issue-19975-pg, not the scratchpad, because the scratchpad is 0700 root and postgres refuses to run as root. PID 7831 was started by me and stopped by me, and the directory was deleted.",
"A read-only fetch of PR #19947's head into refs/os-probe/issue-19975/pr19947 (deleted afterwards) also created the remote-tracking ref origin/claude/issue-19886-cel-mongodb-refusal in the shared .git as a side effect. It was left alone.",
"Commit trailers: model-free per AGENTS.md (Co-authored-by: Claude). The harness reminder asked for a model-named trailer, and the pre-push hook refuses one."
],
"open_questions": [],
"out_of_scope_findings": [
"class: b · The analytics ObjectQL EXECUTE face answers a read-scope list with the engine's shared-face INVALID_FILTER / 400. Its message carries the policy field name and the list values, for example a resolved membership set. Measured through AnalyticsService (objectqlAggregate) over a real ObjectQL + SqlDriver(better-sqlite3):
OBJECTQL-EXECUTE {\"team_id\":[\"t1\",\"t2\"]} -> THROW code=INVALID_FILTER status=400 message=\"aggregate('qa_ticket'): The implicit-equality comparand on field \\\"team_id\\\" requires a single comparable value, but received an array ([\\\"t1\\\",\\\"t2\\\"]) at where.team_id. ...\". The $eq spelling reads the same way. One scope thus gets two envelopes across analytics faces (native/echo 500 withheld, ObjectQL execute 400), and the 400 path is the disclosure #5367 closed for this compiler. Contract (read-scope-sql.ts header, #5367): "A 400 echoed the message verbatim, so ... handed 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." Seam: spec:assertListComparandShapes (INVALID_FILTER/400) → runtime:ObjectQLStrategy.withReadScope → ObjectQL.aggregate lowerWhereFilterArray (engine.ts:870) | renderer: consumer: none. The HTTP echo was NOT MEASURED end to end; the 4xx echo is a code reading of the rest analytics envelope branch. Reachable today from authored CELfield == current_user.MEMBERSHIP; PR #19947 would remove that producer, leaving host-supplied scopes. 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 strategy","carrier: #19886 (ruling A; its stage-2a report 5806337850 already lists read-scope-sql as face 3) · noted, not filed. read-scope-sql still binds a list under $ne (measured:
{\"status\":{\"$ne\":[\"closed\",\"archived\"]}}->(\"qa_ticket\".\"status\" IS NULL OR \"qa_ticket\".\"status\" [SQL-NE] ?)params [["closed","archived"]]), and that shape is reachable from authored CEL!=against a list until PR #19947 lands. Outside ruling 乙 and outside this claim's region.","carrier: #19888 (named in the claim against filter-normalizer.ts) · noted, not filed. The analytics caller where door (lowerAnalyticsWhere) still accepts the object form
{ f: { $eq: [list] } }: frozen matrix cell whereEq = accept, green at 11c11c7. Now that #19882 has merged, #19888's pm:blocked premise may be stale."],
"cleanup": "The private PG was stopped (pg_ctl stop -m fast, "server stopped", PID 7831 gone) and its directory removed. The probe ref was deleted. No dev server and no background process were left running. The only background job was a gate loop that the harness moved to the background at its 600s cap; I waited it out in the foreground until all 61 exit codes were recorded, and it completed.
git status --porcelainprinted 0 lines at 11c11c7, and the remote branch head equals the local head (11c11c7). Thenrm -rf /home/user/objectstack-issue-19975/node_modules && git worktree remove /home/user/objectstack-issue-19975ran without --force and exited 0, and the path is gone. This report was posted from the primary checkout through the relay."}
objectstack-fleet commentedon Sep 24, 2026
Review — substance ACCEPTED, one patch round (Clause-② arm) · PR #19994 · head
11c11c7dc3·domain:servicesseat · 2026-09-24T15:58ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
main, first lineFixes #19975.read-scope-sql.tsplus three test files and.changeset/19975-read-scope-eq-array.md. Nopackages/spec, and nothing outside the claimed region.c647adb6ccbefore the fix (9764b20003).$eq: [...]is unreachable from metadata and reachable from a host-supplied scope. There it bound the list as one parameter, with engine-dependent answers that include over-reach and widening under negation.READ_SCOPE_COMPILE_FAILED/ 500, not the shared face's 400. That is correct: the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 ruling recorded in the file header binds every refusal of this compiler to a withheld 500, and the card's 400 belongs to the authoring door.2484 passed.Clause-② arm:
no (narrowing), with a patch round sent.compileScopedFilterToSqlis exported, and its accept set shrinks, which is the #19952 / #19950 precedent. The changeset goes tominorwith a!headline, a BREAKING line and an adr-0087 not-required marker, and the PR-body line changes to match. The seat edited this card's claim line toClause-②: no (narrowing)in place.Out-of-scope findings, one line each:
filed #19995(class b, the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 contract).$neagainst a list still binds in this compiler: carrier [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 (its stage-2a report lists this face),Acceptance notes.IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888's blocker has closed: the seat re-derived it in this pass (see [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).Seat's own correction. The dispatch called PR #19882 in flight; it had merged at 2026-09-24T14:44Z. The dev caught it.
Generated by Claude Code
objectstack-fleet commentedon Sep 24, 2026
os-dev-report
{
"issue": 19975,
"status": "done",
"round": "patch round 1 (seat review of PR #19994 at 11c11c7: substance accepted; Clause-② arm B, no (narrowing))",
"branch": "claude/issue-19975-read-scope-eq-array",
"pr": "#19994",
"head_sha": "feb810c3d4",
"previous_head": "11c11c7dc3",
"commits_this_round": [
"f25f6a4864 Merge origin/main into claude/issue-19975-read-scope-eq-array (origin/main 276d96d, 14 commits; clean, no conflict, no os-regen deferral)",
"feb810c3d4 chore(changeset): declare the read-scope $eq list refusal a narrowing"
],
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat's session; this run is its subagent)",
"premise_still_valid": true,
"summary": "I recreated the worktree on the same branch; its local head equalled the remote head 11c11c7. I merged origin/main (merge commit only) and rewrote the changeset as the seat ruled:
\"@objectstack/service-analytics\": minor,fix(service-analytics)!:headline, aClause-②: no (narrowing)line, the ADR-0087 markernot-required (no-migration-prescription)in the rls-check-defaults-to-using.md shape with the reason the seat named, and a BREAKING paragraph naming the refused shape and the$inremedy. The prose is abstract, with no per-dialect answers. No code or test changed this round. pr_body_new below carriesClause-②: no (narrowing), a Declaration section, the corrected premise line (#19882 had merged; the dispatch was wrong), the new head and gate figures, and ZERO attribution footers. The PR body itself was not edited (the seat writes it).","files_changed_this_round": [
".changeset/19975-read-scope-eq-array.md"
],
"named_gates": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main — "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/19975-read-scope-eq-array.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)"",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main — "✓ This diff introduces no
majorbump." and "LEVEL AXIS: NOT APPLICABLE — this run has no pull_request to read a declaration from"","exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT (a pull_request payload whose body is pr_body_new) — "✓ LEVEL AXIS: this PR declares clause-②
no (narrowing), and no package whosepackages/**/src/**it moves is gradedpatch." / "direction arm:narrowing— a BREAKING change; during the launch window it shipsminor"","exit 0 | pnpm check:changeset-gate-self-tests — "check-empty-changeset --self-test: 159 assertions", "check-adr-0087-registration --self-test: 441 assertions", "check-changeset-no-major --self-test: 339 assertions"",
"exit 0 | GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs (GH_TOKEN was non-empty) — "board: probed (8 citations), frontier #19995, 9 numbers resolve" / "citations judged: 14 across 1 file(s) / 14 resolves" / "✅ check-issue-citations: every citation this change adds resolves""
],
"gates": {
"derivation": "
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat feb810c derives 61. That is the same set as at 11c11c7: newly derived 0, dropped 0.","reconcile": "
--ranover the new-head record with exit codes: "Run reconciliation — 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN" / "✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".","not_measured": [
"NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET (every package's dist is needed; the fresh worktree built only the service-analytics closure and objectql). CI builds the workspace.",
"NOT MEASURED: pnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET (--re-measure needs the workspace closure built). The diff adds no package, tsconfig or excluded test."
],
"note": "pnpm check:lean-entry-closure first answered exit 3 PREREQUISITE NOT MET (no objectql dist in the recreated worktree). I built @objectstack/objectql under the lock and re-ran it: exit 0, "Admitted set held exactly (15 packages); 6 denied names absent." The record below carries the re-run.",
"all": [
"exit 0 | node scripts/check-adr-0087-registration.mjs --base origin/main",
"exit 0 | node scripts/check-adr-0087-registration.mjs --self-test",
"exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main",
"exit 0 | node scripts/check-changeset-no-major.mjs --self-test",
"exit 0 | node scripts/check-ci-filter-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs",
"exit 0 | node scripts/check-closing-keyword-parity.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-adoption.mjs",
"exit 0 | node scripts/check-comment-mask-adoption.mjs --self-test",
"exit 0 | node scripts/check-comment-mask-corpus.mjs",
"exit 0 | node scripts/check-empty-changeset.mjs --base origin/main",
"exit 0 | node scripts/check-empty-changeset.mjs --self-test",
"exit 0 | node scripts/check-keyed-text-bounds.mjs",
"exit 0 | node scripts/check-keyed-text-bounds.mjs --self-test",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs",
"exit 0 | node scripts/check-platform-object-tenancy-census.mjs --self-test",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs",
"exit 0 | node scripts/check-plugin-teardown-shape.mjs --self-test",
"exit 0 | node scripts/check-registry-log-declared.mjs",
"exit 0 | node scripts/check-registry-log-declared.mjs --self-test",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs",
"exit 0 | node scripts/check-rest-log-spy-declared.mjs --self-test",
"exit 0 | node scripts/check-system-context-census.mjs",
"exit 0 | node scripts/check-system-context-census.mjs --self-test",
"exit 0 | node scripts/check-tenant-audit-census.mjs",
"exit 0 | node scripts/check-tenant-audit-census.mjs --self-test",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs",
"exit 0 | node scripts/check-undeclared-dep-imports.mjs --self-test",
"exit 0 | node scripts/docs-audit/check-affected-docs.mjs",
"exit 0 | node scripts/docs-audit/check-drift-comment.mjs",
"exit 0 | node scripts/pm/release-rehearsal-clone.mjs --self-test",
"exit 0 | pnpm --filter @objectstack/spec run check:duration-unit-keys",
"exit 0 | pnpm check:changeset-gate-self-tests",
"exit 0 | pnpm check:cross-package-test-inputs",
"exit 0 | pnpm check:dispatcher-error-vocabulary",
"exit 0 | pnpm check:doc-authoring",
"exit 0 | pnpm check:driver-memory-census",
"exit 0 | pnpm check:dts-closure",
"exit 3 | pnpm check:dual-build-cjs-loads",
"exit 0 | pnpm check:engine-double-contract",
"exit 0 | pnpm check:gitlink-declared",
"exit 0 | pnpm check:issue-citations",
"exit 0 | pnpm check:lean-entry-closure",
"exit 0 | pnpm check:logger-receiver-detach",
"exit 0 | pnpm check:nul-bytes",
"exit 0 | pnpm check:objectql-double-limit",
"exit 0 | pnpm check:objectui-changeset",
"exit 0 | pnpm check:org-identifier",
"exit 0 | pnpm check:page-declaration-shape",
"exit 0 | pnpm check:pm-changeset-deadline-census",
"exit 0 | pnpm check:published-files",
"exit 0 | pnpm check:query-options-erasure",
"exit 0 | pnpm check:refd-timer-probe",
"exit 0 | pnpm check:slot-lookup",
"exit 0 | pnpm check:sourcemap-no-sources-content",
"exit 0 | pnpm check:test-source-alias",
"exit 0 | pnpm check:tier-file-adoption",
"exit 0 | pnpm check:type-check-coverage",
"exit 3 | pnpm check:type-check-debt",
"exit 0 | pnpm check:watch-hint-literal",
"exit 0 | pnpm check:where-matcher"
]
},
"tests": "The merge brought spec, core and driver-sql changes, so I rebuilt the service-analytics closure under the lock (
pnpm turbo run build --filter='@objectstack/service-analytics...' --concurrency=2, 9 tasks, VERDICT command-exit 0) and re-ran the package on feb810c:pnpm --filter @objectstack/service-analytics test && pnpm --filter @objectstack/service-analytics typecheck-> "Test Files 116 passed (116) / Tests 2484 passed (2484)", VERDICT command-exit 0. No code or test changed this round, so the round-1 ablations (18 and 11 red, restores proven) stand unchanged.","pr_body_new": "Fixes #19975\n\nClause-②: no (narrowing)\n\n## What this changes\n\n
compileScopedFilterToSql(packages/services/service-analytics/src/read-scope-sql.ts) lowers a row-level read scope into the SQL that the analytics NativeSQL path executes and the/analytics/sqlecho prints. It already refused a list in the implicit equality slot ({ f: [...] }) withREAD_SCOPE_COMPILE_FAILED/ 500. The explicit spelling,{ f: { $eq: [...] } }, compiled to an equality with the whole list bound as one parameter, which left the meaning of the predicate to the executing database.\n\nA new gate,assertNoListInEqualitySlot, refuses that spelling incompileField, at any depth under$and/$or/$not, in this module's own envelope. It runs before the member gates, so a list is reported as a list and not by one of its members. This applies ruling 乙 (#19757, record5793368540: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to a compiler that never reaches the shared face.\n\n## Declaration\n\nBREAKING: this narrows whatcompileScopedFilterToSql, exported from@objectstack/service-analytics, accepts. A read scope carrying{ f: { $eq: [...] } }compiled before this change and is refused after it. The remedy is{ f: { $in: [...] } }. The changeset ships the narrowing asminorunder the launch-window convention for accept-set narrowings, with a!on its headline, theClause-②: no (narrowing)line and an ADR-0087not-required (no-migration-prescription)disposition: no authorable key, spelling or stored shape moves, and an authored policy never emits this spelling.\n\n## Measured first\n\nThe measurement was recorded on this branch asc647adb6cc, before any source change. This is an abstract summary; the tests are the pins.\n\n- Authoring door. The published RLS policy schema and the RLS authoring lint's decision procedure both admit an equality predicate whose comparand is a list, whether a list literal or a membership variable.\n- Lowering. That predicate lowers to the implicit spelling. The CEL lowering emits$eqonly around a{ $field }reference, so no authored policy produces a list under$eq. The tenant layer, the sharing read filter and the controlled-by-parent filter do not emit$eqat all.\n- This compiler. The implicit spelling reaches it through the security service's read filter and is refused (500). A list under$eqreaches it only from a host-suppliedgetReadScopeor from a direct caller of the export, and it compiled.\n- Engines. On the NativeSQL execute path the bound list got four different answers depending on the engine: a driver error, zero rows, rows the scope never named, and every row when negated. Measured on better-sqlite3 and sql.js through the drivers, and on a local PostgreSQL 16 throughdriver-sqland through a plainpgpool. MySQL is NOT MEASURED: there is no server in the container.\n\n## Deliberate choices\n\n- 500, not the shared face's 400. The #5367 ruling, re-affirmed as #7598 Q2 = A and recorded in this module's header, keeps every refusal of this compiler atREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld. The scope is a policy the caller cannot author, and a 4xx would echo it back to them. The card's 400 belongs at the policy's authoring door, which is not this file.\n- The module's own wording, not a call into the shared face.assertListComparandShapesthrowsINVALID_FILTER/ 400. It also judges more than the equality slot: list-operator shapes, null members, null ordering comparands and$betweenbounds. Calling it here would change other refusals of this compiler, and each of those has its own ruling on this door. The new sentence follows this module's bare-array refusal, so both spellings of the one condition read the same way in the operator's log.\n-$newith a list is not judged. Ruling 乙 names equality only.$nefalls under ruling A of #19886 and is handled on that card.\n\n## Compile surfaces (a list in the equality slot)\n\n| surface | verdict |\n|:--|:--|\n|compileScopedFilterToSql(service-analytics read scope) | changed. A list under$eqis refused. The implicit list was already refused and is now pinned at every depth. |\n|assertListComparandShapes(spec shared face) | already compliant. This is ruling 乙's own face (#19882, landed). Measured:INVALID_FILTER/ 400 for the implicit list, for$eq, and under$not. |\n|matchesFilterCondition(formula) | already compliant. Measured:INVALID_FILTER/ 400 for the same three shapes (#19886 stage 2a). |\n|applyFilterCondition(driver-sql) | already compliant. It refuses with 400 at the driver and behind the engine's shared-face seam (table in the #19882 changeset; not re-measured here). |\n|buildWhereSQL(driver-turso RemoteTransport) | already compliant. 400 according to the compile-face table in the #19886 stage-2a report; not re-measured here. |\n|checkCondition(driver-memory) | already compliant. 400 at every depth (table in the #19882 changeset). |\n|translateFieldOperators(driver-mongodb) | out of scope. The driver answers with MongoDB array equality. Platform doors reach it only through the shared face, which refuses (the declared scope of #19882). |\n|lowerAnalyticsWhere(analytics callerwhere) | out of scope. This is the caller-authored filter door (theINVALID_FILTER/ 400 family), not a read scope. Its object-form$eqlist cell still readsacceptin the frozen comparand matrix. The claim records #19888 against this file. |\n|applyHaving/matchesHaving(objectql HAVING) | out of scope. A caller-authored filter applied after aggregation, not a read scope. Its answers are recorded in the #19886 stage-2a report. |\n\nThe analytics ObjectQL execute route never calls this compiler. It hands the scope toengine.aggregate, and the engine's shared-face seam refuses the list withINVALID_FILTER/ 400 (measured). See the acceptance notes.\n\n## Tests and evidence (headfeb810c3d4, after mergingorigin/mainat276d96dd23)\n\n- Newsrc/__tests__/read-scope-eq-array-refusal.test.ts, 29 tests:\n -$eqlists at every depth, including negation, and beside another operator in either key order;\n - list-before-member precedence ([undefined],[{ $field }]);\n - the implicit list at every depth;\n - seven neighbouring shapes that must compile unchanged;\n - the NativeSQL execute face and the echo face over a real sql.js engine. Both refuse, and no statement reaches the engine. The prescribed$inserves exactly the rows it names.\n-read-scope-refusal-envelope.test.ts: inventory row ⑯ added, and the ratchet moves to 16 rows over 14 sites.\n-comparand-door-single-source.test.ts: the frozen matrix's read-scope$eqarray cell changes fromacceptto the refusal, with a note. It pinned exactly the bind this PR removes.\n-pnpm --filter @objectstack/service-analytics test: 116 files and 2484 tests passed.typecheckexited 0, andtsc --listFilesincludes all three touched test files.\n- Ablations. Each was run from the committed fix. The mutation went throughscripts/ablation-replace.mjs, and each restore was proven by the blob hash matching HEAD.\n - A: removing the gate call turned 18 tests red. These include every$eqpin, both real-engine faces (zero rows served, and every row served under the negation), and inventory ⑯.\n - B: making the bare-array arm bind turned 11 tests red.\n- Gates.dispatch-gatesderives the same 61 atfeb810c3d4as at11c11c7dc3, and all 61 were re-run onfeb810c3d4: 59 exited 0. Two are NOT MEASURED because their prerequisite was not met (check:dual-build-cjs-loadsandcheck:type-check-debtneed the whole workspace built, and CI builds it). Among the 59:check-adr-0087-registration --base origin/main(1 declared-breaking changeset, carrying its disposition),check-changeset-no-major --base origin/main,check:changeset-gate-self-testsandcheck-issue-citationsin its board-probing mode, all exit 0.\n- Lint, narrowed to the change.eslint --no-inline-config --format jsonover the four touched TypeScript files: 4 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each of them.eslint.config.mjsnever enables type-aware linting (its own note, near line 326), so this diff cannot change the verdict on any untouched file.\n\n## Acceptance notes\n\n- Authoring door, implicit spelling. The authoring-door half of the card for the implicit spelling is work in the #19886 lane: draft PR #19947 refuses==against a list at the CEL lowering and in the lint. #19975 needs nothing more from it.\n- Adjacent finding, filed by the seat, not addressed here. The analytics ObjectQL execute route answers a read-scope list with the engine'sINVALID_FILTER/ 400, not this compiler's 500.\n- Premise correction. PR #19882, ruling 乙's shared face, merged at 2026-09-24T14:44Z, before this branch was cut fromae7a35a63b. The dispatch described it as in flight; it was not.\n","pr_body_note": "pr_body_new has zero attribution footers and zero rule lines. It contains no less-than character, no control byte, and no closing keyword other than line 1
Fixes #19975. It is 9104 bytes. Until the seat writes it, the live body still readsClause-②: no, so a PR-scoped changeset check that reads the body may read the old arm.","mcp_calls": "0 — no MCP GitHub tool was called.",
"api_writes": "1 this round: comment -> POST /repos//issues/19975/comments (this report), via the fleet relay (scripts/pm/with-fleet.sh --via dispatch, one POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]). PR body: not written (seat writes pr_body_new). Labels: 0. Not REST: 1 git push (11c11c7..feb810c, fast-forward).",
"deviations": [
"The seat asked to correct the body's premise line. The round-1 body already said #19882 had merged, so the line is reworded as a "Premise correction" that states the dispatch was wrong. The acceptance note on the ObjectQL execute-face finding now says the seat files it.",
"I ran all 61 derived commands on the new head, not only the four named ones plus the newly derived (of which there were none)."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: the seat (it files the class-b finding) · noted, not filed by me: the analytics ObjectQL execute face echoes policy comparands in an INVALID_FILTER / 400.",
"carrier: #19886 · noted, not filed: $ne with a list in read-scope-sql.",
"carrier: #19888 · noted, not filed: serial after this PR, because it shares comparand-door-single-source.test.ts."
],
"cleanup": "At feb810c,
git status --porcelainprinted 0 lines and the remote head equalled the local head. Thenrm -rf /home/user/objectstack-issue-19975/node_modules && git worktree remove /home/user/objectstack-issue-19975ran without --force and exited 0, and the path is gone. No server or background process was started this round."}
objectstack-fleet commentedon Sep 24, 2026
Landed ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-24T16:51Ze8f163fc3aonorigin/main. Parent count 1 (a queue squash), andassertNoListInEqualitySlotis present inread-scope-sql.tsat that commit. The card closedcompletedthrough the PR's one closing keyword.minor,!,Clause-②: no (narrowing), an ADR-0087 not-required marker. Every check was green before the flip.pm:dispatchedand the assignee. The claim is discharged.IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 (Blocked-by: #19975, the shared frozen comparand matrix). Its unlock scan runs next.Generated by Claude Code
fix(spec)!: refuse an array in the equality slot at the shared compar…
fix(service-analytics): the read-scope compiler refuses a list under …
fix(service-analytics)!: the analytics `where` door refuses a list in…