Skip to content

fix(service-analytics)!: a dataset dimension spelled through a relationship path the dataset does not declare is refused INVALID_FIELD on the native, ObjectQL and draft-preview faces - #28553

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-h8f0nq
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-h8f0nq

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Requested by Leehom · project thread

Fixes #28258

Clause-②: no (narrowing) — a dimension named through a relationship path the dataset's include does not declare is now refused INVALID_FIELD / 400: in a queryDataset selection on all three faces, and in an /analytics/query or /analytics/sql request over a registered dataset cube, where the ObjectQL strategy also served it before (the gate sits in the call context those doors share). The draft preview (previewDrafts: true) served it as one null group, and the ObjectQL strategy served a real lookup (account.name) by joining it anyway; both now refuse. The native strategy refused it DATASET_INVALID / 400 and now answers INVALID_FIELD / 400 for this case only. Nothing is widened: no export, answer member, error code or status is added; both codes were already on the wire.
Metadata-protocol: none
Skills-docs: none — content/docs/data-modeling/analytics.mdx already says "Only declared paths are joinable", which this diff makes true on every face; no file under skills/** or content/docs names the code a selection-side refusal answers.

BREAKING accept-set narrowing on queryDataset (@objectstack/service-analytics), and a code change on one native refusal.

Card thread: session_01FEkQ5pG4c8xoSCyPjcztJc.

维护者速读

  • 改了什么:数据集查询里,维度若经由数据集 include 没声明的关系路径书写(如 对象名.bogus、或没 include account 时写 account.name),三条执行面现在都在服务层同一道闸门按 400 INVALID_FIELD 拒,消息逐字相同,点名该维度、第一个没声明的路径、已声明路径和可用维度。原生 SQL 面这一种情形从 DATASET_INVALID 改为 INVALID_FIELD;草稿预览不再给一个 null 分组;ObjectQL 面不再绕过 include 去 join。
  • 为什么改:卡 analytics: a dotted dimension whose join the dataset does not back gets three different answers from native, ObjectQL and the draft preview #28258(分诊 6532297703,席位裁定 INVALID_FIELD):同一个写错的维度三面三种答复,草稿里还显示一个看似合理的总数。
  • 风险与回滚:只影响「选择里经未声明关系路径的维度」;已声明路径(include: ['account'] 下的 account.name)、解析到已声明维度的点号写法(对象名.region)照旧服务;runtimeFilter 经未声明路径、手写 cube 的配置白名单仍走原生面的 DATASET_INVALID;读不了的关联对象仍先给 403。仓内唯一读到这个错误码的消费方(@object-ui/data-objectstack 适配器)对任何具名错误码一视同仁。回滚即 revert。
  • 先看:packages/services/service-analytics/src/analytics-service.ts 的新方法 assertDimensionJoinsBacked 及其两处调用(callCtx 里字段读闸门之后、answerDataset 预览的闸门列表末尾);dataset-compiler.ts 的 unbackedRelationshipPath。
  • 两分钟验证:pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/dotted-dimension-unbacked-join-parity.test.ts,预期 9 passed。
  • 需要你做的:无,席位落地(收窄需先过合约评审)。

What changes

One gate, three faces (analytics-service.ts, new private assertDimensionJoinsBacked). For each dimensions entry that resolves to no declared dimension (declaredMemberEntry, the strategies' own lookupMember mirror) and is a dotted identifier path, it walks the path hop by hop with unbackedRelationshipPath (new export of dataset-compiler.ts, beside joinAlias; not re-exported from the package entry) against the compiled dataset's allowedRelationships, the set the native join allowlist reads. The first undeclared path is refused through invalidMemberError (dataset-refusal.ts), the member-level INVALID_FIELD / 400 constructor the other member refusals use, carrying member, param: 'dimensions' and cube:

Dimension 'account.owner.name' on cube 'os28258_deals_by_account' traverses relationship path 'account.owner',
which the dataset does not declare in its `include`. Only fields along a declared relationship path are joinable
— declared paths: account. Valid dimensions: region.

It is asked in two places, one message, no copy:

  1. callCtx (shared by query() and generateSql(), so both strategies and the SQL echo), right after the object, stored-body, internal-field and field-level verdicts and before the order-key door and any strategy.
  2. The draft preview's gate list in answerDataset, at the same point: after assertFieldsReadable, before the evaluator.

Why after the admission verdicts, not in ensureCube. The first cut asked it in ensureCube beside the dimension-field gate. packages/rest/src/analytics-relationship-path-admission.test.ts (#20933) then went red on both compositions: a dimension through an undeclared path to an object the caller may not read must answer that object's 403, as a declared one does. Asking the gate after callCtx's admission keeps that 403 (the same reason the order-key door sits there).

Scope kept to the ruling.

  • Only dimensions. A dotted timeDimensions entry already answers one INVALID_FIELD on both live strategies (measured below), so it is left alone.
  • Only on a compiled dataset (allowed defined). A hand-authored cube's configured getAllowedRelationships hook still reaches the native strategy's DATASET_INVALID.
  • The native strategy's allowlist (native-sql-strategy.ts) is untouched; it still answers DATASET_INVALID for a runtimeFilter member through an undeclared path, and for every other reason it is raised today. dataset-compiler.ts's own assertDeclared is untouched.

One pin moved. packages/rest/src/analytics-dataset-refusal-envelope.test.ts's row "native-sql-strategy: a selection naming a join outside the allowlist" used a dotted dimensions entry as its trigger. That is exactly this card's case, now refused ahead of the strategy, so the row's trigger becomes a runtimeFilter member ({ 'account.region': 'emea' }) that still reaches the strategy allowlist: its audit trail (the producer's DATASET_INVALID envelope, listEntry: 'not backed by a declared relationship') is kept, with the same message.

Tests

New packages/services/service-analytics/src/__tests__/dotted-dimension-unbacked-join-parity.test.ts: real ObjectQL + SqlDriver (better-sqlite3, in memory) through AnalyticsServicePlugin.init, the native face, the ObjectQL face and the preview face (a pending seed draft), the #28201 harness.

  • Refused three ways, INVALID_FIELD / 400, param: 'dimensions', member, cube, message first sentence, and the native, ObjectQL and preview messages byte-equal: os28258_deal.bogus (the card's spelling), account.name (a real lookup, no include), account.owner.name (only account included, refused naming account.owner), and account.name beside the declared region.
  • Controls: backed account.name serves Acme 130 / Globex 50 on both live strategies and is not refused on the preview; os28258_deal.region (resolves to the declared region) serves amer 150 / emea 30 on both live strategies and is not refused on the preview; undeclared plain bogus keeps analytics: the dataset draft preview serves a dimension the dataset does not declare, which the live strategies refuse INVALID_FIELD #28201's refusal ("groups by field 'bogus'") on every face; nope beside account.name is answered about the measure on every face; a runtimeFilter member account.name keeps the native DATASET_INVALID / 400.
Red first on main eeec88d, and the per-face answers measured there

The file run against the unchanged base: Tests 4 failed | 5 passed (9); the four refusal cases red ("expected a refusal, but the query resolved": the preview served), the five controls green.

A probe on the same harness, same base, recorded each face:

Selection (dataset) native ObjectQL preview
os28258_deal.bogus (no include) DATASET_INVALID / 400 "join "os28258_deal" is not backed…" INVALID_FIELD / 400 from the driver ("groupBy … names 'os28258_deal.bogus'") served [{ "os28258_deal.bogus": null, total: 180 }]
account.name (no include) DATASET_INVALID / 400 served Acme 130 / Globex 50 served one null group, 180
account.name (include account) served served served one null group, 180
os28258_deal.region (no include) served amer/emea served amer/emea served one null group, 180
Green, ablation, suites
  • Green: the new file 9/9; with the three preview-parity siblings (preview-undeclared-dimension-parity, preview-filter-field-json-dimension-parity, preview-undeclared-measure-parity) 37/37.
  • Ablation (node scripts/ablation-replace.mjs --delete, anchor the preview's this.assertDimensionJoinsBacked(q, cube, compiled.allowedRelationships);, i.e. the null-group behaviour put back): anchor 1 → 0, blob 9b761f324397 → 5c8914397b7f; Tests 4 failed | 5 passed (9), the four refusal pins red (the preview resolved), controls green; restore blob equals HEAD, git diff HEAD empty. The suite imports the service from src/, so no build sits between the mutation and the run.
  • pnpm --filter @objectstack/service-analytics exec vitest run: Test Files 201 passed (201), Tests 4823 passed | 282 skipped (5105).
  • pnpm --filter @objectstack/rest exec vitest run src/analytics (after building rest's dependency closure): Test Files 23 passed (23), Tests 298 passed | 22 skipped (320).
  • pnpm --filter @objectstack/service-analytics typecheck: exit 0.

Acceptance notes

  • Wire-change readers of DATASET_INVALID for this case (measured before changing it, per the order): @object-ui/data-objectstack's queryDataset adapter is the only runtime reader, and it sends every named non-permission code to one generic "Dataset query failed: 400 — detail" error (branch ⑤), so INVALID_FIELD lands identically; its two tests use the code as fixture text only. No reader in packages/client, the console, HotCRM (its one DATASET_INVALID mention is a currency comment), examples/, skills/** or content/docs names this case. In-repo test pins: the rest refusal-envelope row (moved, above); service-analytics dataset-refusal-envelope.test.ts ⑤ and native-sql-rls.test.ts call the strategy directly and are unchanged; dataset-rls-integration.test.ts uses a declared dimension under a configured hook and is unchanged. No real consumer, so both halves land.
  • ObjectQL face narrows too. The card measured OBJECT.bogus; a real lookup the dataset does not include (account.name) was served on the ObjectQL strategy by joining outside the allowlist. The ruling's contract covers it ("a dimension spelled through a join the dataset does not back"), so it is refused there now and pinned; the changeset says so. Because the gate sits in callCtx, the same narrowing reaches /analytics/query and /analytics/sql over a registered dataset cube (the ObjectQL strategy served there too); the Clause-② line and the changeset name both doors (contract review, prose-only follow-up).
  • Preview vs a denied related object. The preview reads no related object (cubeObjects only), so it asks no admission for the path's object; a path that is both undeclared and through an unreadable object answers the field gate's verdict, then this gate's 400 on the preview, where the live faces answer the object's 403. Not measured here; it needs a security-wired preview harness.
  • Out of scope, measured on the same harness at eeec88d14, not fixed here (in the report for the seat):
    • The preview joins nothing, so a BACKED dotted dimension (account.name with include: ['account']) and a dotted spelling that resolves to a declared dimension (OBJECT.region, CUBE.region) serve one null group on the preview where both live faces serve real groups.
    • A dotted timeDimensions entry (account.created_at, no include): both live faces INVALID_FIELD / 400; the preview serves [{ total: 180 }], dropping the time dimension.
    • A runtimeFilter member through an undeclared path (account.name): native DATASET_INVALID, ObjectQL INVALID_FIELD ("cannot evaluate a cross-object filter"), preview [].
    • A declared path to a column the related object lacks (account.bogus, include account): native DATABASE_ERROR / 500, ObjectQL INVALID_FIELD / 400, preview one null group.

Verification

Readings on 9b932230a (code head: the union, the dogfood battery and the full build ran on it). Head 6001927ad adds one commit that only respells two placeholders in the changeset prose (OBJECT.bogus for an angle-bracket form); on it the changeset-reading families were re-run, all exit 0: check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main, check:changeset-gate-self-tests, check:nul-bytes, check:issue-citations, check-closing-keyword-parity, check-commit-card-trailers (4 commits); the derived list on the new head is byte-identical to the one run. Head 0fb1c3aa9 adds one more prose-only commit (the changeset's Clause-② sentence and "Who sees it" name /analytics/query and /analytics/sql, per the contract review); the same eight families re-run there, all exit 0 (first reads exit 3 in a worktree without node_modules, re-read after pnpm install), and the derived list is again identical.

  • Derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 62 families, each run with < /dev/null and its exit code captured before any pipe; --ran reconciliation: ✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3). One first read was a container prerequisite, re-read after meeting it: check:dual-build-cjs-loads exit 3 ("Run pnpm build first") → full pnpm build --concurrency=2 (117 tasks, exit 0) → exit 0. check:type-check-debt exit 0 (0 raw tsc error(s) total, none above its recorded number).
  • Hand gates: node scripts/check-platform-checklist.mjs --changed-from origin/main exit 0; node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD exit 0; pnpm exec eslint --no-inline-config packages/services/service-analytics/src packages/rest/src/analytics-dataset-refusal-envelope.test.ts exit 0, no output (neither package has a lint script; the repo-wide pnpm lint is CI's).
  • Open PRs touching these files: none of the 14 open code PRs at push time (feat(metadata-protocol): ensure door for a read-only base's paired customization package #28512–refactor(metadata,objectql,cli): one spelling of the residual-picklist refusal and of two registration refusal envelopes #28531) changes analytics-service.ts, dataset-compiler.ts or the rest refusal-envelope test.
  • Full dogfood (owed by the ! and the (narrowing) arm): after building its closure, under the lock, pnpm --filter @objectstack/dogfood test → Test Files 363 passed | 3 skipped (366), Tests 2932 passed | 28 skipped (2960); no failure.
  • CI families (Test Core, lint farm, type-check lanes) are read off this PR's check-runs by the seat.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5pG4c8xoSCyPjcztJc


Generated by Claude Code

…th the dataset does not declare is refused INVALID_FIELD on the native, ObjectQL and draft-preview faces

Co-Authored-By: Claude <noreply@anthropic.com>
…e admission verdicts, so a denied object keeps its 403

Co-Authored-By: Claude <noreply@anthropic.com>
…sion refusal

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 5 documentable anchor(s).

⛔ 3 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AnalyticsService (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via AnalyticsService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 212 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a52191f698eadae265807b718cb13f6533c51cff → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c34a5ce3a7e4ddd62e1833dfd34fd7f7fa179296 — the merge of head 0fb1c3aa9811a6a3fc102b851faa9728a5eef23e into base a52191f698eadae265807b718cb13f6533c51cff, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c34a5ce3a7e4ddd62e1833dfd34fd7f7fa179296 && git checkout c34a5ce3a7e4ddd62e1833dfd34fd7f7fa179296
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a52191f698eadae265807b718cb13f6533c51cff 0fb1c3aa9811a6a3fc102b851faa9728a5eef23e && git checkout -B drift-repro a52191f698eadae265807b718cb13f6533c51cff && git merge --no-ff 0fb1c3aa9811a6a3fc102b851faa9728a5eef23e

node scripts/docs-audit/affected-docs.mjs --json a52191f698eadae265807b718cb13f6533c51cff

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a52191f698eadae265807b718cb13f6533c51cff → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 6001927 · 2026-11-01T05:51Z

Face: Clause-②: no (narrowing) ⇒ CONTRACT_REVIEW_TIER with ④, run by a reviewer agent other than the dev, on this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6001927ad3e16daaf51160b9fcc07e57fbff36c7
Local-runs: none
Scope: packages/rest/src/analytics-dataset-refusal-envelope.test.ts, packages/services/service-analytics/src/tests/dotted-dimension-unbacked-join-parity.test.ts, packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/dataset-compiler.ts + packages/services/service-analytics/src/dataset-refusal.ts, packages/services/service-analytics/src/strategies/native-sql-strategy.ts, packages/services/service-analytics/src/strategies/objectql-strategy.ts, packages/services/service-analytics/src/preview-evaluator.ts, packages/services/service-analytics/src/hop-object.ts, packages/services/service-analytics/src/index.ts, packages/spec/src/ui/dataset.zod.ts, content/docs/data-modeling/analytics.mdx, packages/rest/src/analytics-relationship-path-admission.test.ts, objectui packages/data-objectstack/src/index.ts (all read at origin/main 50646f0, objectui origin/main)

① Derived judgments

Read from the diff and the live tree, each accept-set or public-surface change the hunks imply:

  1. queryDataset, native strategy: a selected dimension through an undeclared relationship path moves from DATASET_INVALID / 400 to INVALID_FIELD / 400. Right. On main the only thing refusing it is NativeSQLStrategy.assembleStatement's join allowlist (native-sql-strategy.ts:1497–1526), which reads ctx.getAllowedRelationships(cube); cubeReads answers that from scope.getCompiledDataset(name)?.allowedRelationships (analytics-service.ts:1759–1762). The new assertDimensionJoinsBacked reads the identical set from the identical scope, so what it refuses is exactly the subset of allowlist refusals that come from a dimensions entry, and nothing the allowlist accepted becomes refused. The status is unchanged; only the code and the envelope (member, param: 'dimensions', cube via invalidMemberError, dataset-refusal.ts:183) change. The ruling on the card (claim 6553559505, agreeing with triage 6532297703) asked for exactly this.

  2. queryDataset, ObjectQL strategy: a real lookup the dataset does not include (account.name, no include) was served and is now refused. Right, and it is a narrowing the card's wording covers. ObjectQLStrategy has no allowlist read at all (grep for getAllowedRelationships / allowedRelationships in objectql-strategy.ts returns nothing); its lookupMember (objectql-strategy.ts:1662–1683) mints the same synthetic { sql: member } the native strategy does and planCrossObject then FK-expands it. The spec's own include doc says "D-C: only declared paths are joinable" (dataset.zod.ts:643–648), and the published docs page says "Only declared paths are joinable" (analytics.mdx:248). Serving it was the ObjectQL face failing the spec's refusal; refusing it makes the refusal real.

  3. Draft preview (previewDrafts: true): the one null group is now the same refusal. Right. evaluateAnalyticsQueryOverRows (preview-evaluator.ts:860–866) reads r[String(dim?.sql ?? name)], so an undotted-in-the-row member like account.name reads undefined and every row lands under null. The gate is asked at the same point in the preview's gate list as in callCtx (after assertNoInternalFieldsQueried and assertFieldsReadable), reading compiled.allowedRelationships directly, so the message is byte-equal by construction (one method, no copy).

  4. The gate sits in callCtx, which queryIn (both query() and queryDataset's executor) and generateSql() share. Right as placement: it inherits the security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 order (object exposure, read admission, stored-body, internal-field, field-level, then this gate, then the order-key door at the same seam). It means the narrowing also reaches /analytics/query and /analytics/sql for a cube that is a registered dataset (sharedScope.getCompiledDataset answers from datasetRegistry, analytics-service.ts:1492, 2620). Native already refused there (allowlist); ObjectQL served there and now refuses. Same compiled dataset, same accept set, same gate, so it is the same narrowing, but see ② for the prose.

  5. Hand-authored cubes (no compiled dataset), timeDimensions, where/runtimeFilter members, expression sql, and dotted spellings that resolve to a declared dimension are untouched. Right. allowed is undefined for a non-dataset cube so the gate returns; the loop reads query.dimensions only; declaredMemberEntry (analytics-service.ts:377–405) is the exact mirror of the strategies' lookupMember chain (bag[member], <cube>.<tail>, bag[tail], flattened), so <cube>.region and <object>.region skip the gate as both strategies would resolve them; IDENTIFIER_PATH (line 434) is the same regex qualifyAndRegisterJoin tests before splitting a path, so an expression is passed through as the strategy passes it.

  6. unbackedRelationshipPath (new export of dataset-compiler.ts) is not a public-surface change. Right. src/index.ts re-exports compileDataset and five types from dataset-compiler.js by name (lines 15–22); nothing is re-exported wholesale, so the package's published surface is unchanged. Its hop-by-hop walk uses the same joinAlias (.→__) the compiler registers joins under and resolvePathHops builds aliases with (hop-object.ts:81), and allowedRelationships holds every prefix (dataset-compiler.ts:769), so a fully declared multi-hop path answers null and account.owner.name over include: ['account'] answers account.owner. Correct.

  7. The rest refusal-envelope row's trigger moves from a dotted dimensions entry to a runtimeFilter member. Right. The old trigger is this card's case and is now refused ahead of the strategy; a runtimeFilter member reaches resolveFieldSql → qualifyAndRegisterJoin → the allowlist, which the allowlist's own comment names as a caller-shaped trigger (native-sql-strategy.ts:1505–1510). The listEntry audit trail and the message regex are kept, so the analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 coverage that row exists for still holds.

  8. No DATASET_INVALID consumer is broken by the code change. Verified, not taken from the report. objectui packages/data-objectstack/src/index.ts ~6989–7020: PERMISSION_DENIED is typed (③), a code-less answer is read by status (④), and every other named code, DATASET_INVALID and INVALID_FIELD alike, falls to branch ⑤'s generic Error('Dataset query failed: …'). Its two DATASET_INVALID test files use the code as fixture text. HotCRM has no reader; packages/client, apps, examples, skills and content/docs name DATASET_INVALID only for compile-time (dataset-definition) refusals, never for a selection-side dotted dimension. The triage condition "if the review finds the code has consumers, it comes back as a decision card" is not met.

② Semver level

.changeset/28258-dotted-dimension-unbacked-join.md: '@objectstack/service-analytics': patch, with a BREAKING banner, the Clause-②: no (narrowing) and Metadata-protocol: none lines at line start, an ADR-0087 not-required (no-migration-prescription) marker, and a remedy line. That matches the diff: nothing authorable changes (no spec key, no export, no config field), so under AGENTS.md's level rule ("a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition") patch is the correct level, and skip-changeset would have been wrong because @objectstack/service-analytics publishes. The PR body carries the same two declaration lines at line start; Clause-②: no (narrowing) is the right arm (two faces stop serving, one face changes code; nothing is widened: both codes were already on the wire, no answer member is added). Check Changeset on the head is success.

One precision point, should-fix, prose only: the Clause-② sentence in both the PR body and the changeset names "a queryDataset selection". The gate is in callCtx, so the same narrowing is live on /analytics/query and /analytics/sql whenever cube names a registered dataset (judgment ① 4). The PR body's "What changes" says so ("shared by query() and generateSql()"); the declaration line and the changeset's "Who sees it" do not. Naming the two doors there would make the ledger entry complete. It is the same accept set, so this is wording, not a missing narrowing.

③ Boundary flags

Check-runs on 6001927ad (filter=latest, 50 runs): 16 success (including Check Changeset, Lint · ESLint, Lint · CI/release/engine gates, Lint · PM dispatch-gates, Lint · repo & tooling gates, Type Check · source gates, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate, Flag docs affected, Plan Test Core shards), 17 skipped (UI and opt-in lanes, correctly, for a server-only diff), 0 failures, and 17 still in_progress at review time: Build Core, Test Core 1/8–8/8, Type Check · consumer gates / debt ledger / workspace, Lint · docs & code guards, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Live E2E UI, and a second Check Changeset instance. This record judges on the code; the seat reads those conclusions before landing. The dev's own runs on the code head (9b932230a, which 6001927ad differs from only in changeset prose) report service-analytics 201 files / 4823 tests green, rest src/analytics 23 files green, typecheck 0, 62/62 derived gate families exit 0, and the full dogfood battery 2932 passed / 0 failed; nothing in the diff contradicts those readings.

open_questions: empty. Nothing to answer or escalate.

Dev flags in the os-dev-report (6554100004) and the PR's Acceptance notes:

  • Native code change measured for consumers first. Done correctly; I re-measured it (① 8). No real consumer, so both halves land, as the claim allowed.
  • ObjectQL face narrows too (served account.name). Within the ruling: the card's contract is "a dimension spelled through a join the dataset does not back", which a real lookup outside include is. Pinned in the new test on real SQLite. Right.
  • First cut in ensureCube broke security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933's 403 and was moved to callCtx. The right placement. analytics-relationship-path-admission.test.ts:372–377 pins that an undeclared path to an unreadable related object answers 403 PERMISSION_DENIED, and queryObjects (analytics-service.ts, via namedQueryFields → resolvePathHops → the field's reference) admits the hop's object before this gate runs. The order-key door sits on the same seam for the same reason.
  • Preview vs a denied related object (not measured). The preview admits cubeObjects only (base object and declared joins), so an undeclared path through an unreadable object would answer the field gate's verdict then this gate's 400 where the live faces answer 403. That is a pre-existing property of the preview (it reads no related object), not something this diff introduces or worsens: before, the same request served a null group. It is honestly declared; it does not block.
  • Rest test row moved. Right (① 7).
  • Card assignee untouched, PR without labels at open. Fine; the labels now on the PR are bot-applied.
  • timeDimensions left alone on the claim both live faces already answer INVALID_FIELD. Not changed by this diff, so not a gate here; recorded as out-of-scope finding 2.

The four out-of-scope findings (class a) and the fifth unmeasured note — does any block this PR? No.

  1. Preview serves a null group for a BACKED dotted dimension and for OBJECT.region/CUBE.region. This is the "declared capability not delivered" case: the spec declares a declared path joinable, the preview joins nothing. The PR does the right thing with it: it does not refuse it (the two CONTROL tests assert the preview is not refused for account.name with include: ['account'] and for os28258_deal.region), leaving it an implementation gap for its own card. Not blocking.
  2. Dotted timeDimensions on the preview drops the time dimension. Different member position, same family; its own card.
  3. runtimeFilter through an undeclared path: native DATASET_INVALID, ObjectQL INVALID_FIELD, preview []. The same three-answer split in the filter position. The ruling scoped this card to the dimension, and the native allowlist's DATASET_INVALID for this position is explicitly kept by the claim ("DATASET_INVALID stays for every other reason it is raised"). Its own card.
  4. Declared path to a column the related object lacks: native 500. A separate defect on the native face; its own card.
  5. Preview vs denied related object (covered above). Not blocking.

④ Design judgment

Judged over the four code paths against the spec on main. The question is whether the narrowing takes a declared capability away at the consumer, or makes the spec's own refusal real.

It makes the refusal real. DatasetSchema.include is documented as the one source of joinable paths ("D-C: only declared paths are joinable; no arbitrary predicates", dataset.zod.ts:647–648); compileDataset's header says the same of the compiler ("every dotted field reference must point at a relationship that the dataset explicitly declared in include; otherwise the compile fails", dataset-compiler.ts:35–39) and the returned allowedRelationships is described as "the join allowlist the strategy enforces at SQL-build time". On main that allowlist is enforced by one strategy out of three; the ObjectQL strategy and the preview silently served what the spec says is refused. The PR moves the judgment one layer up, to the seam all three faces share, and reads the same set. No consumer loses anything the spec grants: a declared path serves as before (CONTROL pins on both live strategies, same groups), a dotted spelling that resolves to a declared dimension serves as before, and a hand-authored cube is unaffected.

I tried to break it:

  • Could the gate refuse something the allowlist accepts? No: it reads the same Set the allowlist reads, through the same scope, with the same alias convention and the prefix closure already in the set.
  • Could it pre-empt an earlier verdict? Only verdicts the ruling wanted after it: it runs after the object, stored-body, internal-field and field-level gates, so an unreadable related object still answers 403, and the nope measure CONTROL shows a measure mistake is still answered first on every face (ensureCube's order).
  • Does it diverge from the strategies' member resolution? No: declaredMemberEntry is the documented mirror of both lookupMembers and was already the gate-side resolver for [17.0-rc2验收] analytics: 不存在的 dimension 500(泄漏 SQL / SQLITE_ERROR)而不是 400 指名字段 —— #4437 只给 measure 加了闸门,dimension 侧对称缺口仍在 #5520/analytics: where 里点名不存在的字段仍然一路到驱动 —— #4437(measure)/ #5520(dimension)之后,filter 面是同一个缺陷剩下的第三个 param #5669; IDENTIFIER_PATH is the strategy's own path test.
  • Is the <cube>.bogus spelling handled? Yes, consistently with both strategies: declaredMemberEntry finds no bogus, the member is a path, hop <cube> is not an alias, so it is refused INVALID_FIELD naming the member. Before, native refused it as a join (DATASET_INVALID) for the same reason. The one soft spot is the message: for <cube>.bogus (and the card's own <object>.bogus) it says the member "traverses relationship path ''", which is what both strategies would have done with it but not what the author meant. The "Valid dimensions: …" tail carries the remedy, so this is a should-fix on wording (a clause such as "no such relationship or dimension" would name the mistake either way), not a correctness issue.
  • Does the preview's gate read a different cube from the live faces? It reads compiled.cube with minted measures spread in, the same cube the preview's other gates read; dimensions are not minted, so the result is the same as the live faces' post-ensureCube cube for this question.
  • Is anything widened? No. No new code, status, export, or answer member; the member/param/cube fields are the ones every other invalidMemberError already carries.

Findings: no must-fix. Should-fix (1): name the /analytics/query and /analytics/sql doors over a registered dataset in the Clause-② sentence and the changeset's "Who sees it" (② above). Should-fix (2): the refusal message's "traverses relationship path" clause for a <cube>./<object>. qualified spelling (④ above). Both are prose; neither changes what the gate accepts or refuses.

Implemented-by: claude/project-thread-h8f0nq
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

…the unbacked-join dimension refusal also narrows

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Regen-provenance: 6554203536 · 6001927ad3e16daaf51160b9fcc07e57fbff36c7 → 0fb1c3aa9811a6a3fc102b851faa9728a5eef23e · GET compare/6001927a...0fb1c3aa → 1 commit, 1 file: .changeset/28258-dotted-dimension-unbacked-join.md (+2/-2), prose only

The new commit takes the review's should-fix (1): the changeset's Clause-② sentence and "Who sees it" now name /analytics/query and /analytics/sql over a registered dataset cube. No code or refusal wording changed, so the contract review 6554203536 carries to this head. Should-fix (2), the refusal wording for a <cube>.bogus spelling, is not taken here: it is wording only, and the refusal itself is right.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-01T06:23Z

Head 0fb1c3aa, report 6554100004 (on #28258), claim on #28258. Contract review 6554203536 at CONTRACT_REVIEW_TIER with ④, by a reviewer agent other than the dev, on 6001927a: PASS, no must-fix. It carries to this head by Regen-provenance (one prose-only commit to the changeset).

item reading
Shape Draft, base main, Fixes #28258. 5 files: analytics-service.ts (the unbacked-join gate in callCtx and the preview gate list), dataset-compiler.ts, a new parity test, one rest refusal-envelope row re-triggered, and one patch changeset on @objectstack/service-analytics. Clause-②: no (narrowing), Metadata-protocol: none.
Diff check (seat) A dimension through a relationship path the dataset does not declare is refused INVALID_FIELD / 400 on all three faces: native (was DATASET_INVALID / 400), ObjectQL (was served) and the draft preview (was one null group). This makes the spec's "only declared paths are joinable" (dataset.zod.ts include) real. The changeset now names every door that narrows (queryDataset, and /analytics/query and /analytics/sql over a registered dataset cube); I checked its prose against the diff sentence by sentence. The rest envelope row still pins the native allowlist's DATASET_INVALID, now through a runtimeFilter member.
Evidence CI green on head 0fb1c3aa (37 success, 15 skipped, 0 failed). Governed check: NOT governed, 360 changed lines. Mergeable state clean. The report gives 62 of 62 derived gates run and exit 0, none NOT MEASURED, and the dogfood battery 363 files / 2,932 tests passed. Four out-of-scope findings are filed as #28555–#28558.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit a2e7556 Nov 1, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-h8f0nq branch November 1, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: a dotted dimension whose join the dataset does not back gets three different answers from native, ObjectQL and the draft preview

1 participant