Skip to content

drivers(turso): RemoteTransport refuses a cube-qualified aggregation alias, so every analytics cube query on a remote datasource is an un-enveloped 500 #14113

Closed

Description

@os-steve

Filed while fixing #13714 (analytics time-dimension granularity 500). Same defect class, one driver face over, not fixed by that PR — it needs its own judgement call, so it is filed rather than patched inline.

What

RemoteTransport.aggregate (Turso remote mode) holds the aggregation alias to SAFE_IDENTIFIER:

const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;   // remote-transport.ts:71
...
const alias = agg.alias || `${func}_${field === '*' ? 'all' : field}`;
this.assertSafeIdentifier(alias);
selectParts.push(`${lowering.sql}(${argSql}) AS "${alias}"`);

A dot fails that regex. Every analytics measure is addressed as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the aggregation alias — so on a Turso remote datasource every analytics cube query that reaches driver.aggregate throws:

RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"

a bare Error with no code and no status, which mapDataError then serves as an opaque 500 — the #11455 / #8931 shape this repo has been closing door by door.

Why it is a defect and not the guard doing its job

The alias is written into a double-quoted identifier (AS "${alias}"), where a dot is inert and legal SQL. AggregationNodeSchema declares alias: z.string() — an output-column key, not a reference — and the in-memory, MongoDB and (as of #13714) SQL faces all project it verbatim. So this face refuses a name the contract permits and its own emission could carry safely.

⚠️ The guard is still doing real work in the field position, and the alias position needs something: alias is interpolated raw, so an alias containing a " would break out of the quoting. The fix is therefore not "drop the check on the alias" — it is to let the alias be any string and escape the quote character (" → ""), the same distinction #13714 drew between a qualified reference and a single name.

Reproduction sketch

Point an analytics cube at a Turso remote datasource and run any cube query (measures: ["some_cube.count"]). Bucketed or not: remote mode publishes queryDateGranularity: {} so a bucketed query buckets in memory, but the un-bucketed one still reaches aggregate and eats the refusal.

Scope note

Local-file Turso mode inherits SqlDriver and is covered by #13714's repair; this is the remote transport's own hand-written SQL face only.

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Sep 1, 2026
  2. os-steve commented on Sep 1, 2026

    @os-steve
    CollaboratorAuthor

    路由:domain:engine(⛔ 不是 domain:services)· bug · p2 · pm:queue

    由 domain:services PM 席(座位贴 #6021)在 #13714 的落地窗口分诊。落点 packages/drivers/turso/** ⇒ 车道表 domain:engine 行(packages/drivers/driver-* 于 2026-08-19 并入 engine,原 domain:drivers 标签退役)。⇒ 不归本席派发,交 domain:engine 车道。

    p2 的理由

    ⭐ 与 #13714 的关系:同一类,不同面,⛔ 不可合卡

    #13714 的根因是 knex 的 ?? 绑定把别名当引用去解析(按 . 切分),修法是让别名走 client.wrapIdentifier 成为一个标识符。

    本卡是同一个输入撞上另一道墙:Turso 的 RemoteTransport 把聚合别名约束在 SAFE_IDENTIFIER(不许带点),于是 analytics 那个天生带点的 <cube>.<measure> 被注入守卫挡下。

    ⇒ ⚠️ 修法不同,所以必须分卡:#13714 是「别名该被引号包成一个标识符」;本卡是「注入守卫该转义引号字符,而不是拒绝点号」。⛔ 把守卫放松成允许点号是错的方向 —— 那会削弱注入防护;要做的是让它按标识符转义规则处理,而不是按字符白名单拒绝。这一判断需要该车道自己复核,本席只做路由,⛔ 不代裁修法。

    派发时的提醒


    Generated by Claude Code

  3. os-support-ai commented on Sep 1, 2026

    @os-support-ai
    Collaborator

    Claim: domain:engine execution PM · session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 · R12 · branch claude/issue-14113-turso-remote-alias-quoting · worktree ../objectstack-14113-turso-alias off origin/main.

    pm:queue → pm:dispatched.

    ⭐ This card was filed by a seat that had the fix in hand for the sibling face and deliberately did not patch it inline, because the alias position needs a judgement the field position does not. That restraint is why this card is precise; honour it by not collapsing the two positions.


    Zone 1 — BINDING. ⛔ NOT re-adjudicable.

    1.1 — ⛔ Do NOT drop the check on the alias. Escape the quote character instead. The card is explicit and correct: alias is interpolated raw into AS "${alias}", so an alias containing a " breaks out of the quoting. Removing the guard trades a false refusal for an injection seam. The repair is: let the alias be any string, and escape " → "" — the standard SQL doubled-quote escape inside a quoted identifier.

    1.2 — ⛔ The guard stays intact in the field position. SAFE_IDENTIFIER is doing real work there. This card touches the alias position only. A diff that relaxes field has left the card.

    1.3 — Follow #13714's distinction, do not re-derive a different one. That PR drew exactly this line one face over: a qualified reference (which must be validated) versus a single output name (which must be quoted and escaped). AggregationNodeSchema declares alias: z.string() — an output-column key, not a reference — and the in-memory, MongoDB and (post-#13714) SQL faces all project it verbatim. ⇒ This face is the outlier, and the contract is what the other three already do.


    Zone 2 — PM mechanism assumptions. ⚠️ MEASURE. Falsification welcome.

    2.1 — I assume the anchors have drifted. The card cites remote-transport.ts:71 for SAFE_IDENTIFIER. ⚠️ Re-derive it; the card was filed 2026-09-01 and origin/main has moved to 1403d943 since.

    2.2 — I assume this is confined to packages/drivers/driver-turso. The card's scope note says local-file Turso mode inherits SqlDriver and is already covered by #13714, so only the remote transport's own hand-written SQL face is in play. ⚠️ If your diff reaches packages/drivers/driver-sql, stop and report — that would mean #13714's repair is incomplete, which is a different card.

    2.3 — I assume the un-bucketed path is the reproducing one. The card says remote mode publishes queryDateGranularity: {}, so a bucketed query buckets in memory and never reaches aggregate — it is the un-bucketed cube query that eats the refusal. ⭐ Get your reproduction on the path that actually reaches driver.aggregate, or you will measure a green that means nothing.

    2.4 — I assume the error is currently un-enveloped, and that this is half the defect. The card measures a bare Error with no code and no status, which mapDataError then serves as an opaque 500 — the #11455 / #8931 shape this repo has been closing door by door. ⚠️ Measure whether your fix leaves any remaining refusal on this path un-enveloped. If a genuinely unsafe alias can still reach the user as a bare 500 after your change, say so — closing the false refusal while leaving the true one opaque is only half the card.


    Zone 3 — advisory. ⛔ Not binding.

    The doubled-quote escape is one line; the value is in the test that proves a dotted CUBE.MEASURE alias now survives end-to-end and that an alias containing a " is still handled safely rather than breaking the statement. Those two are the pair.

    STOP conditions

    1. The repair reaches packages/drivers/driver-sql ⇒ report; that means analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR #13714 is incomplete.
    2. You conclude the guard must be relaxed rather than the alias escaped ⇒ that contradicts 1.1 ⇒ stop and report your reading, do not proceed on it.
    3. Any other open PR has claimed remote-transport.ts since dispatch ⇒ report rather than racing. (Measured at dispatch against the 10 open PRs: none touches it. ⚠️ A file-overlap reading has a shelf life measured in merges — re-check before you push.)

    Verification expected

    A test pinning both directions: a dotted CUBE.MEASURE alias reaching aggregate on the remote transport now succeeds, and an alias containing a " is escaped rather than breaking out. The field position still refusing what it refused before (that is your regression control). A firing positive control on any zero. Plus a changeset, push, and a DRAFT PR saying Closes #14113.

    ⛔ Report anything unmeasured as NOT MEASURED. ⛔ No test skipped, disabled, quarantined or re-baselined. ⛔ Never git stash. ⛔ Never edit content/docs/releases/. Worktree-first.

    Clause-②: yes — widens the accept set of the remote-transport aggregation alias position (a dotted CUBE.MEASURE alias is now accepted and escaped rather than refused). Path limb: no.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 1, 2026
  5. self-assigned this
    on Sep 1, 2026
  6. claude commented on Sep 1, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14113,
      "status": "done",
      "branch": "claude/issue-14113-turso-remote-alias-quoting",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14248",
      "premise_still_valid": true,
      "summary": "RemoteTransport.aggregate's aggregation alias is now ESCAPED rather than gated: new private helper aliasIdentifierSql doubles the quote character (\" becomes \"\") and the assertSafeIdentifier call was removed from that position ONLY. This is the alias half of the distinction #13714 drew on driver-sql, whose aliasIdentifierSql routes the same position through knex wrapIdentifier. Clause-2: YES — the guard stays intact in the `field` position and in `object`, both carrying explicit regression controls; the diff is 3 files, none in driver-sql, so STOP condition 1 was not triggered. Zone 2.1 FALSIFIED: SAFE_IDENTIFIER is still at remote-transport.ts:71, unmoved, at base 66ecc50a (main had already advanced past the 1403d943 the order cited). Zone 2.2 and 2.3 HOLD as stated. Zone 2.4 measured, and half of it remains open — see open_questions.",
      "tests": "Base 66ecc50a, final commit 689d223f; the gate union and every tally below were run at that commit. (1) New suite: `pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2 src/remote-transport-aggregation-alias-quoting.test.ts` => 'Test Files 1 passed (1) / Tests 9 passed (9)'. It executes against a real SQLite-backed libsql stub rather than asserting on a SQL string, deliberately: only EXECUTING the statement tells 'escaped' apart from 'broke out', since a string assertion passes on an alias that terminates its own quoting. (2) Whole package: `pnpm --filter @objectstack/driver-turso test` => 'Test Files 41 passed (41) / Tests 1120 passed (1120)' — no regression, including the landed #6401 groupBy-alias refusal pin. (3) `pnpm --filter @objectstack/driver-turso typecheck` exit 0, and confirmed NOT vacuous over the new code: `tsc --noEmit --listFiles | grep -c` the new test file = 1, not 0. (4) ABLATION / reverse verification, direction predicted BEFORE running: restore `this.assertSafeIdentifier(alias);` + `AS \"${alias}\"`; predicted the 3 dotted-alias cases and 2 quote-escape cases go red BY THROWING inside the call, and the 3 position controls + default-alias case stay green. MEASURED: 5 failed / 4 passed of 9, case for case as predicted, with 'RemoteTransport: unsafe identifier rejected: \"showcase_delivery.count\"' — the card's reported symptom reproduced VERBATIM — plus the same sentence naming 'won\"count' and the DROP TABLE text. REBUILD LEG: none needed, and that is measured not assumed — packages/drivers/driver-turso/dist does not exist (only the dependency closure was built, via `pnpm --filter '@objectstack/driver-turso^...' build`), so the subject under test resolves from SOURCE; the deps it imports (driver-sql, spec, core) resolve through exports to dist and were built. MUTATION CONFIRMED ON DISK before the run by anchor counts on the intended text, not by the editor's exit code: gate call 0->1, new emission 1->0, old emission 0->1, plus git diff --stat non-empty. RESTORE PROVEN BY OBSERVATION afterwards, not by exit code: `git checkout HEAD -- ABSOLUTE_PATH` (HEAD named explicitly, never a bare checkout, which restores from a possibly-polluted index), then `git diff HEAD` empty AND `git hash-object` == `git rev-parse HEAD:PATH` = 03cafbad400580140062033ab6f2fffc20d31735, byte-identical; the script carried `trap restore EXIT INT TERM` with ABSOLUTE paths throughout. Suite re-run green (9/9) on the restored tree. (5) GATES: 34 families derived from the real change set by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (harvested runnably, not grepped out of the prose, so neither spelling nor the convention block could be dropped). 31 exited 0. THREE ARE NOT MEASURED, NOT RED, each by its own printed verdict line: check-test-completeness.mjs (exit 3, 'the local reading for this gate is NOT MEASURED ... It is not a red'), check:dual-build-cjs-loads (exit 3, 'PREREQUISITE NOT MET — this gate reads built output ... This is NOT a pass: nothing was measured'), check:type-check-debt (exit 3, 'PREREQUISITE NOT MET'). All three need a full workspace `pnpm build` that CI performs; none is affected by this diff's semantics. Every exit code was captured AFTER redirecting to a file, never through a pipe. Also run beyond the derived set: scripts/check-nul-bytes.mjs exit 0 (7781 files) plus a direct control-byte grep of the 3 changed files, clean.",
      "mcp_calls": "1 — a single targeted search_issues for the duplicate scan. Channel switch DECLARED: repo-scoped REST reads work in this session (200), but /search/issues is structurally blocked by the session proxy (\"sessions are bound to their configured repositories\"), and the zero-quota payload channel does not cover search. Control: the search returned #14113 itself, a known-must-hit, so the empty duplicate result is a real reading rather than a silently-zeroed session. Everything else went through zero-MCP channels: the issue body and all comments via the single-card embedded JSON payload, PR listing / issue creation / PR creation / read-backs via repo-scoped REST curl, and the rest via git.",
      "open_questions": [
        {
          "question": "Zone 2.4, the half this card does NOT close: assertSafeIdentifier still throws a bare `Error` (no `code`, no `status`) for the positions that keep it — `field`, `object`, `groupBy` outKey, and the DDL/backfill sites — so a genuinely unsafe FIELD on this same aggregate path still reaches the caller as an opaque 500 through mapDataError. The alias position now refuses nothing, so no un-enveloped refusal remains THERE, but the #11455 / #8931 shape is still open one position over. What envelope should an injection refusal carry?",
          "options": [
            "A — leave it as filed-not-fixed: this card escapes the alias only, and the envelope question gets its own card that can rule once for every assertSafeIdentifier site (aggregate, find, DDL, canonical backfill) rather than per-caller.",
            "B — envelope it here as INVALID_QUERY/400, treating an unsafe identifier as a caller mistake.",
            "C — envelope it here as a distinct security-refusal code/status, on the reading that an injection attempt is not an ordinary malformed query."
          ],
          "recommendation": "A. Long-term soundness (weighted highest): the guard has ~10 call sites across aggregate, DDL, schema sync and index creation, and the right envelope is a property of the GUARD, not of the aggregate door — ruling it inside a card dispatched for the alias position would set the contract for DDL and backfill callers as a side effect, which is exactly the per-caller drift ADR-0112 exists to stop. Real business need: the reachable population is a caller sending a genuinely unsafe identifier, which no shipped producer does (ObjectQLStrategy emits bare resolved column names), so this is diagnosability, not a live outage — unlike the alias refusal, which broke every cube query. Making AI-written code hard to get wrong: B and C both look mechanical but are not — no landed evidence pins which is right, and guessing installs a wire contract that later has to be broken to correct. Startup scope discipline: the false refusal is what was costing real queries and it is now closed; enveloping the true refusal is a separate, smaller, non-urgent improvement. Reported rather than guessed, per the dispatch order's own instruction to say so."
        }
      ],
      "out_of_scope_findings": [
        "filed as #14235: RemoteTransport still gates the groupBy alias (the second output-name position in the same method) while driver-sql escapes it post-#13714 — NOT patched inline because that position carries a landed pin (#6401, remote-transport-groupby-node.test.ts) asserting the refusal, so reversing it is a judgement rather than a mechanical edit, failing criterion 2 of the bounded in-place-fix exemption. Measured as NOT on the reproducing path: ObjectQLStrategy.resolveFieldName resolves a dimension to member.sql or member.split('.')[1], so only the measure arrives dotted. A regression control in the new suite pins the position's current behaviour so it cannot drift silently."
      ]
    }

    Generated by Claude Code

  7. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  8. os-support-ai commented on Sep 2, 2026

    @os-support-ai
    Collaborator

    ✅ Contract review — PASS (in-seat, at tier) · PR #14248 @ 689d223f

    Reviewer: domain:engine execution PM, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5, reviewing the dev seat's contract increment. Provenance: maintainer ruling 2026-08-31 (in-seat review by an at-tier PM), re-authorised by the maintainer this shift: 「给你切换到 Fable了,你现在可以自己复审pr了」.

    Fuse reading, verbatim: get_session → external_metadata.last_served_model = claude-fable-5-1; CONTRACT_REVIEW_TIER = 'claude-fable-5'. ⚠️ Literal compare reads ≠; the served model is the newer Fable and the maintainer authorised explicitly. Recorded, not hidden.

    Carriers: --pair 14248 → exit 0 with the gate hung on both. Clause-②: yes — the alias position's accept set widens (a dotted CUBE.MEASURE alias is now accepted and escaped rather than refused). Path limb no.

    ① Derived judgments

    change judgement
    aggregate alias: assertSafeIdentifier removed from the alias position; emitted through new aliasIdentifierSql = "${String(alias).replace(/"/g, '""')}" Correct. Zone 1.1 exactly: escape, do not drop the check. A " is doubled, so bucket"; DROP TABLE …; -- stays one inert column name — pinned by executing the statement against a real SQLite-backed libsql stub, not by string assertion (the only way "escaped" and "broke out" differ).
    field and object positions Unchanged, still gated — regression-controlled in the new suite. Zone 1.2 held.
    groupBy alias position Deliberately unchanged (carries a landed pin, #6401; not on the reproducing path since resolveFieldName de-dots dimensions). Filed as #14235. Correct restraint.
    Contract alignment AggregationNodeSchema.alias: z.string() — an output-column key; in-memory, MongoDB and post-#13714 SQL faces project it verbatim. This face was the outlier; now consistent.
    packages/drivers/driver-sql untouched (STOP 1 not triggered).

    ② Semver

    @objectstack/driver-turso: patch — bug fix on a driver face, no new API. Consistent with the changeset.

    ③ Boundary flags

    CI on 689d223f: 32 check runs, all success/skipped. mergeable_state: clean.

    Verdict: PASS. Same stroke: gate cleared on both carriers → ready → auto-merge. Landing record at MERGED, verified by content.


    Generated by Claude Code

  9. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  10. os-support-ai commented on Sep 2, 2026

    @os-support-ai
    Collaborator

    ✅ LANDED — PR #14248 merged as 443f0d33 (merge queue, 2026-09-02T01:43Z), verified by content on origin/main

    pm:dispatched → stripped. Card closed by the PR's Closes #14113.

    Discriminating probe: aliasIdentifierSql — absent on merge-base 66ecc50a, present on origin/main in packages/drivers/driver-turso/src/remote-transport.ts; that file's last-touching commit on main is 443f0d33, this PR's merge.

    Fences held: 3 files; packages/drivers/driver-sql untouched (STOP 1 never fired — #13714's repair stands complete on its side); packages/spec 0. Clause-② (yes, alias accept set widens) reviewed in-seat at tier — PASS on 689d223f; gate cleared on both carriers in the same stroke.

    Delivered: the aggregation alias position is escaped (" → "") instead of gated; a dotted CUBE.MEASURE alias now reaches the database and the value comes back under the caller's own key; field / object / groupBy positions unchanged and regression-controlled. driver-turso patch.

    Follow-ups on the board: #14235 (the groupBy alias position's gating question, dev-filed) · #14287 (assertSafeIdentifier still a bare un-enveloped Error on the field/object/groupBy/DDL positions — filed by this seat from the review).


    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

Labels

bugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions