Repository navigation
service-analytics: the NativeSQL execute face and the /analytics/sql echo bind a read-scope filter placeholder ({current_user_id}, an unknown {token}) as a literal string, where the ObjectQL face resolves it — one scope, different rows across faces #20075
Description
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsBlocked-by: #19995
分诊首次定级:
priority:p2·security·bug·domain:services·pm:blocked—— 分析服务的原生 SQL 执行面和/analytics/sql回显,把读范围里的占位符({current_user_id}、未知的{token})当成普通字符串原样绑定;ObjectQL 面会解析它们,于是同一个读范围在不同的面上得到不同的行Path:
packages/services/service-analytics/src/read-scope-sql.ts(第 558 行起的compileScopedFilterToSql,NativeSQLStrategy.applyReadScope和ObjectQLStrategy.generateSql回显都经过它)Triage: lands in
service-analytics⇒domain:services,security,bug,priority:p2,pm:blockedBlocked-by #19995; rationale: the three analytics faces are held to "one verdict" for one read scope (the #13926 header), but the native execute face and the echo bind a scope placeholder as the literal string (measured by the #19995 dev:{current_user_id}bound as'{current_user_id}'; under$nethat admits every row where the ObjectQL face admits all but the caller's; an unknown{token}is served 200) — a read-scope divergence that can widen, reachable only through a host-suppliedgetReadScope(the RLS compiler emits concrete values), hence p2 withsecurity, the class of #20018; PR #20072 (#19995) is in flight onread-scope-sql.ts(87 lines), so this lands after it.分诊席 #6015,2026-09-25T02:22Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/main7f1de2eb66上核对。本席核对
read-scope-sql.ts第 558 行起是export function compileScopedFilterToSql(。- 在
service-analytics/src里没有找到调用resolveFilterTokens或resolveWhereTokens的地方,与卡面「原生面和回显不解析占位符」一致。 - PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072(security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with
INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995)用主干提交记录核对过,还没合并。本地取它的改动:read-scope-sql.ts改了 87 行,正是本卡要改的文件。 - 卡面的探针本席没有重跑。
定级说明
p2 加
security:- 在
$ne这类条件下,原生面会比 ObjectQL 面多放出行,属于读范围放宽。 - 只有宿主自己提供
getReadScope、并在里面用了占位符时才会触发;行级安全编译器输出的是具体值,不会触发。已发布的宿主里有没有这种写法,卡面没有测。 - 与 service-analytics: the NativeSQL read-scope compiler and the
/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 同级。
解锁后的执行要点
- 让原生面和回显走同一个解析:在
compileScopedFilterToSql里用引擎那一套规则解析占位符,和 ObjectQL 面得到同一个结论。 - 解析不了的占位符按 fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072 的方式拒绝:返回隐去细节的
READ_SCOPE_COMPILE_FAILED/ 500,⛔ 不能原样绑定。 - 钉子:同一个带
{current_user_id}的读范围(等于、$ne两种写法),三个面行集一致;未知{token}在三个面上得到同一种拒绝。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 25, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsUnlock re-derivation: the blocker's reason is gone ·
domain:servicesseat · 2026-09-25T02:50ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021. Read againstorigin/main7ddf396109.- Blocker:
Blocked-by: #19995. Triage's stated reason was file-surface serialisation: PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072 (security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995) was in flight onread-scope-sql.ts. That PR merged as60fdaa9e27. - security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with
INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 itself stays open. It is nowneeds-user-decision(comment 5825715530) with nothing in flight. Its remaining work is an engine-side judgement at the ObjectQL face, which does not supersede this card's native / echo binding. - Re-derived, still owed: on
7ddf396109,service-analytics/srcstill has noresolveFilterTokenscall on the native / echo read-scope path.compileScopedFilterToSqlbinds a scope placeholder as written. - State:
pm:blocked→pm:queue;domain:services,priority:p2andsecurityare unchanged. - Serial: service-analytics:
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068 (released in the same pass) also lands inread-scope-sql.tsand is claimed first. This card is dispatched after that card's PR lands.
Generated by Claude Code
- Blocker:
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-20075-native-scope-placeholders
Worktree:objectstack-issue-20075
Domain:domain:services
Seat:domain:services#1
File surface:packages/services/service-analytics/src/read-scope-sql.ts(compileScopedFilterToSqland its options);src/strategies/native-sql-strategy.ts(applyReadScope,:654onmain) andsrc/strategies/objectql-strategy.ts(the echo's scope compile,:564), only to hand the execution context to the compiler; new test file(s) underpackages/services/service-analytics/src/;.changeset/20075-*.md. ⛔ Nopackages/core(resolveFilterTokensis used as published), nopackages/objectql,specorrest. ⛔ Not the ObjectQL face's engine-door residue (#19995, in the decision box). (Stop on breach; explain in the report.)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no
Thread-read: 5825872287
Serial constraints cleared at 2026-09-25T04:32Z: PR #20096 (#20068,read-scope-sql.ts) landed asa8bcce69c8, and PR #20072 (#19995's placeholder class) as60fdaa9e27. No open PR touchesservice-analytics. #19995 has nothing in flight.objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20075,
"status": "done",
"branch": "claude/issue-20075-native-scope-placeholders",
"pr": "#20111",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat's; mode:subagent, so the parent's)",
"premise_still_valid": true,
"summary": "compileScopedFilterToSql now resolves a read scope's filter placeholders with resolveFilterTokens(scope, filterTokenContextFrom(context)) from @objectstack/core over a new optional ReadScopeCompileOptions.context, and lowers the resolved tree. NativeSQLStrategy.applyReadScope (base and every joined hop) and the echo's compile in ObjectQLStrategy.generateSql pass ctx.context (the spec StrategyContext.context AnalyticsService.callCtx binds, so no spec/core/objectql/rest/service change). A placeholder the resolver refuses gets READ_SCOPE_COMPILE_FAILED / 500, withheld, through one module-local helper that assertReadScopePlaceholdersResolvable (PR #20072) now shares, so the envelope and sentence have one spelling. Placement: resolution BEFORE the lowering (it binds values); the refusal is held and raised AFTER the lowering's own gates (incl. the #20068 $icontains arm) and the #20018 comparand faces, the engine's order, so a doubly-refused scope keeps the other gate's sentence; the #13926 vacancy guard stays at the merge sites after the compiler. The dispatch's default for a context-less export call (refuse every placeholder) was falsified against the control face: the engine resolves a date macro context-less (UTC) and refuses a context token, so an absent context answers exactly that; a placeholder is never bound as its literal. Card premise held: measured on a8bcce6 (table below, recorded as branch commit 8bf233a before any fix).",
"measurement_table": "Measured at a8bcce6 (branch commit 8bf233a carries the full record). Real SqliteWasmDriver; real ObjectQL behind the ObjectQL face; echo SQL executed on the same DB; every face through AnalyticsService, caller { userId: 'u_me' }, no tenantId. deal rows: d1 (owner u_me, closed_on 2020-01-10), d2 (u_other, 2099-02-10), d3 (u_me, 2020-03-10), d4 (owner NULL, closed_on NULL); account rows acc_gold (owner u_me, 2020-01-01), acc_silver (u_other, 2099-01-01).\nscope | compileScopedFilterToSql binds | native base | native joined hop | echo (SQL run) | ObjectQL base (control) | ObjectQL hop (control)\nowner = {current_user_id} | the literal | none | none | none | d1 d3 | gold=2 (restricted)=2\nowner $ne {current_user_id} | the literal | d1 d2 d3 d4 | gold=2 silver=2 | d1 d2 d3 d4 | d2 d4 | silver=2 (restricted)=2\nowner $in [{current_user_id}, u_nobody] | the literal + u_nobody | none | none | none | d1 d3 | gold=2 (restricted)=2\nowner = an unknown {token} | the literal | none, served | none, served | none, served | READ_SCOPE_COMPILE_FAILED/500 | READ_SCOPE_COMPILE_FAILED/500\nowner = {current_org_id}, no tenantId (FILTER_TOKEN_UNRESOLVED) | the literal | none, served | none, served | none, served | READ_SCOPE_COMPILE_FAILED/500 | READ_SCOPE_COMPILE_FAILED/500\nclosed_on $lt {today} (date macro) | the literal | d1 d2 d3 | gold=2 silver=2 | d1 d2 d3 | d1 d3 | gold=2 (restricted)=2\nCONTROL owner = u_me | u_me | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2\nCONTROL owner $ne u_me | u_me | d2 d4 | silver=2 | d2 d4 | d2 d4 | silver=2 (restricted)=2\nCONTROL owner $in [u_me, u_nobody] | u_me, u_nobody | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2\nCONTROL closed_on $lt 2026-09-25 | 2026-09-25 | d1 d3 | gold=2 | d1 d3 | d1 d3 | gold=2 (restricted)=2\nJoined hop: shapes differ per face by design (NativeSQL filters base rows; ObjectQL cross-object path buckets RESTRICTED_BUCKET, ADR-0021 D-C), so each hop is read against its own literal twin: ObjectQL hop placeholder rows == twin; NativeSQL hop placeholder rows != twin on every row.\nNo ExecutionContext (base table): NativeSQL binds the same literals (none; d1 d2 d3 d4 under $ne; d1 d2 d3 for the date macro). ObjectQL control: every context-token scope and the unknown token → READ_SCOPE_COMPILE_FAILED/500; the date macro RESOLVES (UTC) → d1 d3.\nHTTP doors (throwaway harnesses, not committed): runtime dispatcher POST /api/v1/analytics/query and /api/v1/analytics/sql (anonymous): eq / $ne {current_user_id}, unknown {token}, $lt {today} → 200 each, literal bound, the /analytics/sql echo params print the literal. rest POST /analytics/dataset/query (exec ctx userId test-user): the same four plus {current_org_id} → 200 each, literal bound.\nAFTER the fix (same harnesses, service-analytics rebuilt): every base-table face and both hops equal the literal twin for eq / $ne / $in / date; unknown and {current_org_id} → READ_SCOPE_COMPILE_FAILED/500 on all faces and both hops; no context: context tokens → 500 on all three faces, date macro → d1 d3 on all three. HTTP: runtime (anonymous) context tokens and unknown → 500 code READ_SCOPE_COMPILE_FAILED message 'Internal server error', date → 200 bound ['2026-09-25'] and echo params ['2026-09-25']; rest dataset door (test-user) eq/$ne → 200 bound ['test-user'], unknown/unresolved → 500 READ_SCOPE_COMPILE_FAILED 'Internal server error', date → 200 bound ['2026-09-25'].",
"tests": "New: packages/services/service-analytics/src/tests/read-scope-placeholder-three-faces.test.ts, 22 tests, real SqliteWasmDriver + real ObjectQL (aliased to src), three faces + both joined hops. Final head eafeacc: 'Test Files 1 passed (1) / Tests 22 passed (22)'.\nPackage suite at eafeacc (after both main merges, objectql + driver-sql + driver-sqlite-wasm rebuilt): pnpm --filter @objectstack/service-analytics test → 'Test Files 125 passed (125) / Tests 2908 passed (2908)'; typecheck exit 0 (tsc --listFiles includes the new test file; 123 files under src/tests in the program). Lock VERDICT command-exit 0.\nAblations (node scripts/ablation-replace.mjs, WRAP mode, run under the lock; subject resolved by relative import to src, no dist in the path, so no build leg; each leg restore-proven: git hash-object == HEAD blob and git diff HEAD empty, plus my own absolute-path trap and prove_restored):\n A1 resolution removed (anchor ' lowered = resolveReadScopePlaceholders(filter, alias, options.context);' → ' lowered = filter;', anchor 1→0, blob b7abc37a743d → d0aa2f9f854c): 'Tests 18 failed | 4 passed (22)'; survivors = fixture control, caller-where 400 control, doubly-refused sentence pin (nothing to refuse), byte-for-byte pin (so those pins encode the pre-fix output). Restored blob b7abc37a743d == HEAD.\n A2 refusal raised early (' unresolvable = e as Error;' → ' throw e;', blob → 5c639db824bf): 'Tests 1 failed | 21 passed (22)', the red one is 'a scope another gate also refuses keeps that gate's sentence'. Restored == HEAD.\n A3 refusal dropped (' if (unresolvable) throw unresolvable;' deleted, blob → a1b498cdda33): 'Tests 9 failed | 13 passed (22)': all five REFUSED rows, no-statement, context-less, placeholder-sentence, export-without-context. Restored == HEAD.\n A4 native call-site context dropped (' context: ctx.context,' deleted in native-sql-strategy.ts, blob 745b0c5e1da1 → 7063032fa155): 'Tests 6 failed | 16 passed (22)'. Restored == HEAD.\n A5 echo call-site context dropped (' context: ctx.context,' deleted in objectql-strategy.ts, blob 88ee39f822f1 → 486a089cc2af): 'Tests 5 failed | 17 passed (22)'. Restored == HEAD.\nEvery negative pin in the file went red under at least one leg; the one positive-only pin family (byte-for-byte unchanged, fixture, caller-where) stayed green under A1 by design.\nReverse direction observed: the expected one (red) on every leg; no inversion.\nHTTP doors: measured before and after with throwaway harnesses in packages/runtime and packages/rest (never committed, deleted before the PR); readings are in measurement_table.\nLint (narrowed, a measurement): ① population: eslint --print-config resolves a config for the changed file and the JSON run lists all 4 files (none ignored); ② count from --format json: 4 files, 0 errors, 0 warnings, exit 0; ③ invariance: the printed config has parserOptions.project null and projectService null (eslint.config.mjs states it never enables type-aware linting), so the diff cannot move any untouched file's verdict. At eafeacc. Repo-wide pnpm lint is CI's.",
"gates": {
"head": "eafeaccea2 (union run after the final commit; derivation 'gate list derived from the tree ... at commit eafeacc', 61 families, same set as at 7c21462)",
"derived_vs_dispatch_list": "dispatch-time list 55; derived adds 6: check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher (all run).",
"results": [
"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 0",
"pnpm check:dual-build-cjs-loads :: exit 3",
"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 0",
"pnpm check:type-check-debt :: exit 3",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
],
"not_measured": [
"pnpm check:dual-build-cjs-loads :: exit 3, PREREQUISITE NOT MET (reads every package's dist; ~38 packages never built in this worktree). Narrowed spot check, not a substitute: require('packages/services/service-analytics/dist/index.cjs') loads and compileScopedFilterToSql resolves a placeholder (exit 0).",
"pnpm check:type-check-debt :: exit 3, PREREQUISITE NOT MET (needs the whole-workspace closure built: pnpm exec turbo run build over ./packages/*). check:type-check-coverage exit 0."
],
"ran_reconciliation": "dispatch-gates --ran with exit codes: 'Run reconciliation — 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN' (the 2 derived from recorded exit 3).",
"local_state_note": "On the first union (7c21462) check:dts-closure exited 1 naming the 15 packages I had built with OS_SKIP_DTS=1 for the throwaway HTTP harness; I deleted those dists (returning them to never-built) and it went green (15 built packages, 71/71 declaration files); on eafeacc it is exit 0 on the first run.",
"issue_citations": "GITHUB_TOKEN=... node scripts/check-issue-citations.mjs: 'citations judged: 14 across 3 file(s) / 14 resolves' exit 0 (at 7c21462 and again at eafeacc).",
"widening_tells": "node scripts/pm/check-widening-tells.mjs --declaration no: 4 files NOT MEASURED (no declared surface covers service-analytics); exit 0 is evidence about no surface."
},
"files_changed": [
"packages/services/service-analytics/src/read-scope-sql.ts (ReadScopeCompileOptions.context; compileScopedFilterToSql resolves before lowering, raises the refusal after the gates; resolveReadScopePlaceholders helper shared with assertReadScopePlaceholdersResolvable; header #20075 section)",
"packages/services/service-analytics/src/strategies/native-sql-strategy.ts (applyReadScope passes context: ctx.context)",
"packages/services/service-analytics/src/strategies/objectql-strategy.ts (echo scope compile passes context: ctx.context)",
"packages/services/service-analytics/src/tests/read-scope-placeholder-three-faces.test.ts (new)",
".changeset/20075-native-scope-placeholders.md (new)"
],
"commits": "8bf233a1fc measurement record (empty) · 0e2e571 fix · cfcf55f test · 5903718 changeset · 7c21462 merge origin/main 8d76c2d · eafeacc merge origin/main aa04ea2 (objectql having face; objectql rebuilt, suite + gates re-run). No rebase, no force-push.",
"deviations": [
"Changeset level and arm: dispatch default was 'patch' with a 'Clause-②: no' line; shipped 'minor' with 'Clause-②: no (narrowing)', a '!' summary, a BREAKING banner and an ADR-0087 'not-required (no-migration-prescription)' marker (check-adr-0087-registration reads it: [BREAKING+bang+clause-②-narrowing], exit 0). Reasons: (1) the NativeSQL face, the echo and the public export now refuse scopes they used to serve, the accept-set narrowing #20018 declared 'no (narrowing)' / minor for the same alignment on the same faces; (2) the Check Changeset WHICH LEVEL rule (maintainer ruling, decision batch #35) makes a new accepted key on a published package's public surface at least minor, and ReadScopeCompileOptions.context is one. The PR body's declaration line is the claim's 'Clause-②: no' verbatim. Revert = drop the arm, '!', banner and marker in that one file and set patch.",
"Context-less export behaviour: the dispatch's default suggestion (refuse any placeholder when there is no context) was falsified against the ObjectQL control (the engine resolves a date macro context-less, UTC). Implemented the engine's context-less answer instead; pinned both halves.",
"Throwaway harnesses: in packages/services/service-analytics, packages/runtime and packages/rest (never staged or committed, deleted before the PR). To run the runtime/rest ones I built ~15 packages JS-only (OS_SKIP_DTS=1) in my worktree; they tripped check:dts-closure once (local state) and were deleted.",
"Harness-written 'Co-Authored-By' trailer: none; commits carry the model-free pair per AGENTS.md (the attribution reminder's model-bearing trailer was not used)."
],
"mcp_calls": "0 — no MCP GitHub tool was called (reads went through single-card REST GETs: issue 20075, its comments, PR 20072/20096 commit lists, PR 20111 read-back).",
"api_writes": "3 — all through the fleet relay (objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches executing one op: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls draft (run 36099404620, success) = PR #20111; (2) label-write --assign os-sales → POST /repos//issues/20111/assignees (run 36099448404, read-back matches; zero labels written: the dispatch names none and skip-changeset does not apply); (3) this os-dev-report comment → POST /repos//issues/20075/comments via post-stamped. Plus git pushes (not REST).",
"open_questions": [
{
"question": "Semver/declaration for the changeset: keep 'minor' + 'Clause-②: no (narrowing)' + BREAKING (as shipped), or return to the dispatch's 'patch' + 'Clause-②: no'? And should the claim's own line read 'yes (narrowing)' because ReadScopeCompileOptions.context is a new accepted option key on a public export?",
"options": [
"A: keep as shipped (minor, no (narrowing), BREAKING, ADR-0087 no-migration-prescription); claim line stays 'no'",
"B: patch + plain 'Clause-②: no' (the dispatch default); the narrowing then ships without the arm, the #16296 shape",
"C: A, and re-declare the claim/PR line 'Clause-②: yes (narrowing)' for the new option key (contract-review tier question for the seat)"
],
"recommendation": "A: it matches #20018's declaration for the identical alignment and the Check Changeset level rule for a new accepted key, and the arm is what makes check-adr-0087-registration see the narrowing; whether the option key also makes Clause-② 'yes' is the seat's call (C), since the declaration is the claim's."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in Acceptance notes, not filed — the joined-hop shape differs per strategy (NativeSQL filters base rows by the joined object's scope; the ObjectQL cross-object path keeps them under RESTRICTED_BUCKET). Documented design (ADR-0021 D-C, cross-object-rebucket.ts), independent of placeholders; thenonecontrol row shows it pre-fix.",
"carrier: none (承接者:无) · noted, not filed — a scope's date macro resolves at compile time rather than at the query's pinned tokenCtx instant (AnalyticsService pins one instant for the caller's own tokens); same property as the ObjectQL face, whose engine resolves at aggregate time. Microsecond skew, boundary-only.",
"carrier: none (承接者:无) · noted, not filed — @objectstack/core resolveFilterTokens' walk exempts only Date, so when a placeholder is present a non-Date class-instance comparand is copied as a plain object (verified: Uint8Array → {"0":101}, Map → {}), while its docblock says 'Dates and other class instances are comparands'. Every such comparand is refused by the shared comparand faces in both forms, so no verdict changes (only which refusal sentence); not a reproducible defect today."
],
"cleanup": "Branch fully pushed (local == remote eafeacc) before removal; rm -rf ../objectstack-issue-20075/node_modules && git worktree remove (no --force) → exit 0; throwaway harnesses deleted; no server or background process was started.",
"pr_body_new": "Fixes #20075\nClause-②: no\n\n## What this changes\n\nA read scope now gets one placeholder verdict on all three analytics faces: the same resolved values and rows, or the same withheld 500.\n\ncompileScopedFilterToSql(read-scope-sql.ts) is the lowering behind the NativeSQL execute face (NativeSQLStrategy.applyReadScope, the base table and every joined hop) and the/analytics/sqlecho (ObjectQLStrategy.generateSql). It never resolved a filter placeholder, so both faces bound{current_user_id},{current_org_id}or a date macro as literal text. The ObjectQL execute face hands the same scope to the engine, which resolves it with the caller's context and, since #19995, refuses one it cannot resolve.\n\nThe compiler now takes an optionalReadScopeCompileOptions.context. It resolves the scope withresolveFilterTokens(scope, filterTokenContextFrom(context))from@objectstack/core, the published resolver the engine calls, and lowers the resolved tree. Both call sites passctx.context. A placeholder the resolver refuses is refused asREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld.\n\nThat judgement has one spelling: a module-localresolveReadScopePlaceholders. The existingassertReadScopePlaceholdersResolvable(PR #20072, used at the ObjectQL merge sites) now calls it too. There is no second implementation of token classification or resolution, and no second spelling of the envelope or its sentence.\n\n## Measured before the fix\n\nRecorded on this branch (8bf233a1fc, an empty commit whose message carries the full table), on basea8bcce69c8. The setup: a realSqliteWasmDriver, a realObjectQLbehind the ObjectQL face, and the echo's SQL run on the same database. Every face is driven throughAnalyticsServicewith the caller{ userId: 'u_me' }and no active org. Rows d1 and d3 are owned by u_me, d2 by u_other, and d4 has no owner.\n\n| read scope | compiler binds | native base | echo (SQL run) | ObjectQL base (control) |\n|:--|:--|:--|:--|:--|\n|owner = {current_user_id}| the literal | none | none | d1 d3 |\n|owner $ne {current_user_id}| the literal | d1 d2 d3 d4 | d1 d2 d3 d4 | d2 d4 |\n|owner $in [{current_user_id}, u_nobody]| the literal | none | none | d1 d3 |\n|closed_on $lt {today}| the literal | d1 d2 d3 | d1 d2 d3 | d1 d3 |\n| an unknown{token}| the literal | served, none | served, none | 500 withheld |\n|{current_org_id}with no active org | the literal | served, none | served, none | 500 withheld |\n\nThe$nerow widened the scope: the literal matches nobody, so the exclusion also admitted the caller's own rows. The joined hop was measured too. Each face is compared with its own literal twin there, because the two strategies shape a hop differently by design: NativeSQL filters base rows, and the ObjectQL cross-object path uses theRESTRICTED_BUCKET. On the ObjectQL hop every placeholder row equalled its twin. On the NativeSQL hop none did.\n\nWith no context, the ObjectQL control refused every context-token scope and still resolved the date macro (UTC), serving d1 d3.\n\nHTTP doors (throwaway harnesses inpackages/runtimeandpackages/rest, read and measured only, not committed):\n-POST /api/v1/analytics/queryand/api/v1/analytics/sql: 200 with the literal bound, and the echo printed the literal.\n-POST /analytics/dataset/query: 200 with the literal bound.\n\nReach: the card's reach is confirmed by reading. It takes a host-suppliedgetReadScope. The auto-bridgedsecurity.getReadFiltercomposes concrete values: the sharing predicate, the RLS compiler's output and the controlled-by-parent derivation.\n\n## Where the resolution sits among the scope gates, and why\n\n- Before the lowering, because the lowering binds values. What is bound, and what the echo prints, is the value the engine resolves.\n- The refusal is raised after the lowering's own gates (the #20068$icontainsarm among them) and after the #20018 comparand faces. This is the engine's order: its lowering doors run before its resolver. No gate's verdict moves either way. Resolution only replaces a fully-wrapped placeholder string with a non-empty string, and it copies the tree around it, where a non-Dateclass-instance comparand becomes a plain object; every such comparand is refused in both forms. So a scope that another gate also refuses keeps that gate's sentence. This is pinned.\n- The #13926 vacancy guard still runs at the two merge sites after the compiler returns, unchanged.\n- Context at the call sites:native-sql-strategy.ts:654andobjectql-strategy.ts:564both holdctx.context. That is the specStrategyContext.context, whichAnalyticsService.callCtxbinds from thecontextargument ofquery()/generateSql(). It is the same valuewithReadScopealready hands the ObjectQL-face assertion. Nothing inspec,core,objectql,restor the service changes.\n\n## The public export without a context\n\nI did not follow the default suggestion to refuse every placeholder when there is no context, because it disagrees with the control face. The engine resolves a date macro for a context-less operation (UTC now) and refuses a context token. Refusing every placeholder would give the NativeSQL face and the echo a second answer wheneverctx.contextisundefined. So an absentcontextresolves the way the engine does: a date macro resolves against UTC, and a context token or an unknown placeholder gets the withheld 500. A placeholder is never bound as its literal text. Both halves are pinned, and so is the context's time zone being read.\n\n## Tests (ateafeaccea2)\n\n- New file:read-scope-placeholder-three-faces.test.ts, 22 tests. It uses a real SQLite database and a realObjectQL, with the ObjectQL face, the echo (its SQL executed) and the NativeSQL face, plus both joined hops. It pins:\n - each resolvable scope admits its literal twin's rows on every face;\n - both echoes print the resolved value;\n - each refused scope gets the withheld 500 on all three faces and both hops, before any native statement runs;\n - the context-less answer is the same on every face;\n - the sentence ordering;\n - placeholder-free scopes compile byte-for-byte as before, with or without a context;\n - the caller's ownwhereis unchanged: it still resolves, and still answersFILTER_TOKEN_UNKNOWN/ 400 with its message;\n - the public export's context-less decision.\n-pnpm --filter @objectstack/service-analytics test: 125 files / 2908 tests passed.typecheckexit 0, and--listFilesincludes the new test file.\n- Ablations, throughnode scripts/ablation-replace.mjsin WRAP mode. The tests import the mutated sources by relative path (src), so no build sits between mutation and measurement. Each leg was proven restored:git hash-objectequals the HEAD blob, andgit diff HEADis empty.\n\n| leg | mutation | red |\n|:--|:--|:--|\n| A1 | resolution removed (lowered = filter) | 18 of 22; the 4 survivors are the controls, including the byte-for-byte pin, which shows those pins encode the pre-fix output |\n| A2 | refusal raised early (throw e) | 1: the sentence-ordering pin |\n| A3 | refusal dropped | 9: every refusal, no-statement, context-less and sentence pin |\n| A4 |context: ctx.contextremoved atapplyReadScope| 6 |\n| A5 |context: ctx.contextremoved at the echo's compile | 5 |\n\n- HTTP doors after the fix (the same throwaway harnesses, not committed):\n - anonymous/analytics/queryand/analytics/sql: a context token gets the withheld 500; a date macro gets 200 with the resolved date bound and printed.\n -/analytics/dataset/querywith a user: 200 with the user id bound; an unknown or unresolvable placeholder gets the withheld 500.\n\n## Gates (ateafeaccea2)\n\n-dispatch-gates --commandsderived 61 families. 59 exited 0.\n- Two are NOT MEASURED because a prerequisite is missing (exit 3):\n -check:dual-build-cjs-loadsneeds every package'sdist. A narrowed spot check does pass: theservice-analyticsCJS build loads and resolves a placeholder.\n -check:type-check-debtneeds the whole-workspace closure built.\n---ranreconciliation: 61 accounted, 0 UNRUN.\n-check-issue-citationswith a token: 14 citations, all resolve.\n- ESLint, narrowed to the 4 changed.tsfiles: 0 errors and 0 warnings over 4 files, read from the--format jsonoutput. ESLint's own--print-configshows noparserOptions.projectand noprojectService, so this config is not type-aware and the diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.\n-mainmoved twice during the work and was merged both times, never rebased (7c2146223c,eafeaccea2). The dependencies each merge touched were rebuilt before the suite and every gate above were re-run on the final head.\n\n## Changeset classification: differs from the dispatch default\n\n.changeset/20075-native-scope-placeholders.mdisminor,Clause-②: no (narrowing), BREAKING, with an ADR-0087not-required (no-migration-prescription)disposition. The dispatch's default waspatch. Two reasons:\n- The NativeSQL face, the echo and the public export now refuse scopes they used to serve. #20018 made the same alignment on the same faces and was declaredno (narrowing)/minor.\n- TheCheck Changesetlevel rule counts a new accepted key on a published package's public surface as at leastminor, andcontextis such a key.\n\nThe declaration line at the top of this body is the claim's, verbatim. Reverting topatchmeans dropping the arm, the!, the banner and the marker in that one file.\n\n## Acceptance notes\n\n- Date-macro instant. A scope's date macro resolves when the compiler runs, not at the query's pinned instant. That is the same property as the ObjectQL face, where the engine resolves ataggregatetime.\n- Log sentence for a scope with two defects. If a scope carries a vacancy shape and also a bad placeholder, the log sentence differs between faces: the vacancy guard runs after the compiler on NativeSQL and the echo, but first on the ObjectQL face. The wire envelope is identical. The #20018 comparand faces already have the same asymmetry.\n- Joined hops. The two strategies still differ in shape on a joined hop (row filter versusRESTRICTED_BUCKET). That is a documented design, not this card's."
}objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsReview — ACCEPT (landing gated on CI) · PR #20111 · head
eafeaccea2·domain:servicesseat · 2026-09-25T05:44ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
-
PR shape. Draft, targeting
main. The first line isFixes #20075, andClause-②: nostarts its own line. -
Scope. 5 files, +664 / −3:
read-scope-sql.ts;- one line each in
native-sql-strategy.tsandobjectql-strategy.ts, which hand overctx.context; - one new 22-case test file;
- the changeset.
No
core,objectql,specorrestsource. NOT governed. It merges clean withorigin/main. -
Diff, read line by line.
compileScopedFilterToSqlresolves the scope with the publishedresolveFilterTokens(scope, filterTokenContextFrom(context))over a new optionalReadScopeCompileOptions.context, and lowers the resolved tree.- A resolver refusal is held, the lowering's own gates run first (the service-analytics:
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068$icontainsarm and the service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 faces), and only then is the refusal raised as the withheldREAD_SCOPE_COMPILE_FAILED/ 500. That is the engine's order: a doubly-refused scope keeps the other gate's sentence. - The judgement has one spelling. PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072's
assertReadScopePlaceholdersResolvablenow calls the sameresolveReadScopePlaceholders. - The caller's
wherepath is untouched.
-
Measurement. Executed at base, with the ObjectQL face as the control.
owner $ne {current_user_id}widened the scope on the native face and the echo: the literal matched nobody, so the exclusion admitted the caller's own rows.- An unknown or unresolvable token was served 200.
- After the fix, every face and both joined hops equal their literal twin, or give the withheld 500. HTTP was re-measured on both doors.
-
Tests. The measurement commit came first. Package 2908 green, typecheck 0. Ablations A1–A5 each go red, including each call site's
contexthand-off on its own (A4 / A5). Every restore is blob-identical.
Deviations accepted.
- The export without a context. The seat's default ("refuse every placeholder") was falsified against the control face: the engine resolves a date macro context-less, in UTC. The export now gives the engine's answer, and a placeholder is never bound as its literal. Both halves are pinned. Correct; the seat's suggestion was wrong.
mainwas merged twice (no rebase), with the closure rebuilt before the final runs.
Open question (changeset level / declaration): A (keep as shipped).
minorwith!andClause-②: no (narrowing), the honest ADR-0087no-migration-prescriptionmarker, and the claim lineno. It is the same declaration service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 carried for the same alignment on the same faces.- The new optional
contextoption on aservice-analyticsexport is a package API addition,minorunder the Check Changeset level rule. It is not apackages/speccontract, so it does not make Clause-②yes.
Out-of-scope findings, one line each (all Acceptance notes, carrier: none):
- Joined-hop shape per strategy (documented design, ADR-0021 D-C).
- A scope's date macro resolves at compile time rather than at the query's pinned instant: boundary-only, the same as the ObjectQL face.
resolveFilterTokenscopies a non-Dateclass instance to a plain object when a placeholder is present. No verdict changes, because the shared faces refuse both forms.
Landing. No Clause-② limb is hit. The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR goes ready and auto-merge when every check is green. #20098 (
objectql-strategy.ts) is dispatched after this lands.
Generated by Claude Code
-
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-25T06:19Z- PR fix(service-analytics)!: the NativeSQL face and the /analytics/sql echo resolve a read-scope placeholder with the caller's context, as the ObjectQL face does (#20075) #20111 →
7c1039b388onorigin/main. Parent count 1 (a queue squash). At that commit,compileScopedFilterToSqlresolves the scope atread-scope-sql.ts:675, andNativeSQLStrategy.applyReadScopepassescontext: ctx.contextatnative-sql-strategy.ts:661. The card closedcompletedthrough the PR's one closing keyword. - A read scope's filter placeholders now get one verdict on all three analytics faces. The native face (base and every joined hop) and the
/analytics/sqlecho resolve them with the caller's context, giving the ObjectQL face's values and rows. An unknown or unresolvable placeholder gets the withheldREAD_SCOPE_COMPILE_FAILED/ 500, and no placeholder is ever bound as its literal text. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Carried off this card: none open. The three Acceptance notes (joined-hop shape, date-macro instant, class-instance copy) have no carrier.
Generated by Claude Code
- PR fix(service-analytics)!: the NativeSQL face and the /analytics/sql echo resolve a read-scope placeholder with the caller's context, as the ObjectQL face does (#20075) #20111 →
Filing gate: ① a defect with a named landing site. A sibling face does not hold a declared contract.
domain:servicesexecution seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021).INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 dev on PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072, as an out-of-scope finding (class b). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.packages/services/*once triage routes it. The fix lands inpackages/services/service-analytics/src/read-scope-sql.tscompileScopedFilterToSql, the lowering behindNativeSQLStrategy.applyReadScopeand theObjectQLStrategy.generateSqlecho.The contract
read-scope-sql.ts's header, analytics: read-scope-sql's ruled $not-over-$in-empty residue has no open card — and #13640 turned it into an echo-vs-execution disagreement on the ObjectQL strategy #13926 section: the three analytics faces are held to "one verdict" for one read scope.ObjectQL.resolveWhereTokens' docblock (packages/objectql/src/engine.ts): the engine resolves{…}filter placeholders because it is "the ONE gate every server-side read passes through".READ_SCOPE_COMPILE_FAILED/ 500.The defect
Measured by the #19995 dev with a probe at
3557f85fa5:AnalyticsServiceon the NativeSQL face, with a capturingexecuteRawSql.{current_user_id}in the equality slot bound the literal string'{current_user_id}'.$nebound the literal too. The scope then admits every row where the ObjectQL face admits all but the caller's.{token}was served (200), with the literal bound.So one read scope gets different rows, and a different envelope, on the native execute face and the echo than on the ObjectQL face.
Reach: only scopes that carry placeholders, which means a host-supplied
getReadScope. The RLS compiler emits concrete values. Whether any shipped host supplies such a scope is NOT MEASURED.Filing-gate answers
domain:servicesseat after triage.closedincluded. Querynative sql read scope placeholder bound literally compileScopedFilterToSql resolveFilterTokens current_user_id not resolved analytics→ 20 hits (top 10 read):$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068, security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995, service-analytics: the object-form analyticswhereskips the shared comparand-TYPE face, so a plain-object / Map / oversized-bigint comparand is bound as JSON text on the native path while the FilterArray spelling and the engine refuse 400 #20035, service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018, read-scope-sql compiles$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975, [finding] the analytics draft-preview matcher answers every row for $icontains, $startsWith, $endsWith and other operators it has no case for — a drafted chart ignores those filters, then changes at publish #19810, service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684, [finding] analyticsfetchRecordLabelsconsumes the referenced object's read scope with neithercompileScopedFilterToSqlnorassertReadScopeCannotVacatein front — a fourth read-scope door outside the three faces #14322 unified #14329, runtime:POST /analytics/queryrefuses the arraywherethe objectui adapter now sends for every array-form filter —AnalyticsQueryRequestSchema.whereisFilterConditionSchema, whilelowerAnalyticsWhere(the gate ui#6302 measured) accepts filter AST #15828, skills/objectstack-ui teaches$currentUseras a filter value, contradicting its own "only two tokens resolve in a filter" contract #14139.INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 / PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072 cover the ObjectQL face's refusal envelope, not the native / echo faces' binding. skills/objectstack-ui teaches$currentUseras a filter value, contradicting its own "only two tokens resolve in a filter" contract #14139 is a skill-doc token contract.Dedupe words:
native sql read scope placeholder bound literally·compileScopedFilterToSql resolveFilterTokens scope·read scope token three faces one verdictGenerated by Claude Code