Skip to content

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

@objectstack-fleet

Filing gate: ① a defect with a named landing site. A sibling face does not hold a declared contract.

The contract

The defect

Measured by the #19995 dev with a probe at 3557f85fa5: AnalyticsService on the NativeSQL face, with a capturing executeRawSql.

  • A scope with {current_user_id} in the equality slot bound the literal string '{current_user_id}'.
  • The same placeholder under $ne bound the literal too. The scope then admits every row where the ObjectQL face admits all but the caller's.
  • A scope with an unknown {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

Dedupe words: native sql read scope placeholder bound literally · compileScopedFilterToSql resolveFilterTokens scope · read scope token three faces one verdict


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-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:blocked Blocked-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 $ne that 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-supplied getReadScope (the RLS compiler emits concrete values), hence p2 with security, the class of #20018; PR #20072 (#19995) is in flight on read-scope-sql.ts (87 lines), so this lands after it.

    分诊席 #6015,2026-09-25T02:22Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main 7f1de2eb66 上核对。

    本席核对

    定级说明

    p2 加 security:

    解锁后的执行要点

    1. 让原生面和回显走同一个解析:在 compileScopedFilterToSql 里用引擎那一套规则解析占位符,和 ObjectQL 面得到同一个结论。
    2. 解析不了的占位符按 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,⛔ 不能原样绑定。
    3. 钉子:同一个带 {current_user_id} 的读范围(等于、$ne 两种写法),三个面行集一致;未知 {token} 在三个面上得到同一种拒绝。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock re-derivation: the blocker's reason is gone · domain:services seat · 2026-09-25T02:50Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021. Read against origin/main 7ddf396109.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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 (compileScopedFilterToSql and its options); src/strategies/native-sql-strategy.ts (applyReadScope, :654 on main) and src/strategies/objectql-strategy.ts (the echo's scope compile, :564), only to hand the execution context to the compiler; new test file(s) under packages/services/service-analytics/src/; .changeset/20075-*.md. ⛔ No packages/core (resolveFilterTokens is used as published), no packages/objectql, spec or rest. ⛔ 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 as a8bcce69c8, and PR #20072 (#19995's placeholder class) as 60fdaa9e27. No open PR touches service-analytics. #19995 has nothing in flight.

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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; the none control 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/sql echo (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 optional ReadScopeCompileOptions.context. It resolves the scope with resolveFilterTokens(scope, filterTokenContextFrom(context)) from @objectstack/core, the published resolver the engine calls, and lowers the resolved tree. Both call sites pass ctx.context. A placeholder the resolver refuses is refused as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld.\n\nThat judgement has one spelling: a module-local resolveReadScopePlaceholders. The existing assertReadScopePlaceholdersResolvable (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 base a8bcce69c8. The setup: a real SqliteWasmDriver, a real ObjectQL behind the ObjectQL face, and the echo's SQL run on the same database. Every face is driven through AnalyticsService with 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 $ne row 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 the RESTRICTED_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 in packages/runtime and packages/rest, read and measured only, not committed):\n- POST /api/v1/analytics/query and /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-supplied getReadScope. The auto-bridged security.getReadFilter composes 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 $icontains arm 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-Date class-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:654 and objectql-strategy.ts:564 both hold ctx.context. That is the spec StrategyContext.context, which AnalyticsService.callCtx binds from the context argument of query() / generateSql(). It is the same value withReadScope already hands the ObjectQL-face assertion. Nothing in spec, core, objectql, rest or 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 whenever ctx.context is undefined. So an absent context resolves 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 (at eafeaccea2)\n\n- New file: read-scope-placeholder-three-faces.test.ts, 22 tests. It uses a real SQLite database and a real ObjectQL, 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 own where is unchanged: it still resolves, and still answers FILTER_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. typecheck exit 0, and --listFiles includes the new test file.\n- Ablations, through node scripts/ablation-replace.mjs in 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-object equals the HEAD blob, and git diff HEAD is 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.context removed at applyReadScope | 6 |\n| A5 | context: ctx.context removed at the echo's compile | 5 |\n\n- HTTP doors after the fix (the same throwaway harnesses, not committed):\n - anonymous /analytics/query and /analytics/sql: a context token gets the withheld 500; a date macro gets 200 with the resolved date bound and printed.\n - /analytics/dataset/query with a user: 200 with the user id bound; an unknown or unresolvable placeholder gets the withheld 500.\n\n## Gates (at eafeaccea2)\n\n- dispatch-gates --commands derived 61 families. 59 exited 0.\n- Two are NOT MEASURED because a prerequisite is missing (exit 3):\n - check:dual-build-cjs-loads needs every package's dist. A narrowed spot check does pass: the service-analytics CJS build loads and resolves a placeholder.\n - check:type-check-debt needs the whole-workspace closure built.\n- --ran reconciliation: 61 accounted, 0 UNRUN.\n- check-issue-citations with a token: 14 citations, all resolve.\n- ESLint, narrowed to the 4 changed .ts files: 0 errors and 0 warnings over 4 files, read from the --format json output. ESLint's own --print-config shows no parserOptions.project and no projectService, so this config is not type-aware and the diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.\n- main moved 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.md is minor, Clause-②: no (narrowing), BREAKING, with an ADR-0087 not-required (no-migration-prescription) disposition. The dispatch's default was patch. 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 declared no (narrowing) / minor.\n- The Check Changeset level rule counts a new accepted key on a published package's public surface as at least minor, and context is such a key.\n\nThe declaration line at the top of this body is the claim's, verbatim. Reverting to patch means 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 at aggregate time.\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 versus RESTRICTED_BUCKET). That is a documented design, not this card's."
    }

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20111 · head eafeaccea2 · domain:services seat · 2026-09-25T05:44Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main, ⛔ not against the report's own account.

    Checklist

    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.
    • main was merged twice (no rebase), with the closure rebuilt before the final runs.

    Open question (changeset level / declaration): A (keep as shipped).

    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.
    • resolveFilterTokens copies a non-Date class 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

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · 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 → 7c1039b388 on origin/main. Parent count 1 (a queue squash). At that commit, compileScopedFilterToSql resolves the scope at read-scope-sql.ts:675, and NativeSQLStrategy.applyReadScope passes context: ctx.context at native-sql-strategy.ts:661. The card closed completed through 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/sql echo resolve them with the caller's context, giving the ObjectQL face's values and rows. An unknown or unresolvable placeholder gets the withheld READ_SCOPE_COMPILE_FAILED / 500, and no placeholder is ever bound as its literal text.
    • Removed in this stroke, with read-back: pm:dispatched and 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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions