Repository navigation
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
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract-review-claim: session_01R1q2xdtYgU6FKrZ7LgjPCC · head 6001927 · 2026-11-01T05:51Z Face: |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Contract reviewServed-tier: ① Derived judgmentsRead from the diff and the live tree, each accept-set or public-surface change the hunks imply:
② Semver level
One precision point, should-fix, prose only: the ③ Boundary flagsCheck-runs on
Dev flags in the os-dev-report (6554100004) and the PR's Acceptance notes:
The four out-of-scope findings (class a) and the fifth unmeasured note — does any block this PR? No.
④ Design judgmentJudged over the four code paths against the spec on It makes the refusal real. I tried to break it:
Findings: no must-fix. Should-fix (1): name the Implemented-by: VERDICT: PASS Generated by Claude Code |
…the unbacked-join dimension refusal also narrows Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Regen-provenance: 6554203536 · The new commit takes the review's should-fix (1): the changeset's |
|
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC 席位核对 · ACCEPT ·
|
| 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
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
includedoes not declare is now refusedINVALID_FIELD/ 400: in aqueryDatasetselection on all three faces, and in an/analytics/queryor/analytics/sqlrequest 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 onenullgroup, and the ObjectQL strategy served a real lookup (account.name) by joining it anyway; both now refuse. The native strategy refused itDATASET_INVALID/ 400 and now answersINVALID_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.mdxalready says "Only declared paths are joinable", which this diff makes true on every face; no file underskills/**orcontent/docsnames 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、或没 includeaccount时写account.name),三条执行面现在都在服务层同一道闸门按 400INVALID_FIELD拒,消息逐字相同,点名该维度、第一个没声明的路径、已声明路径和可用维度。原生 SQL 面这一种情形从DATASET_INVALID改为INVALID_FIELD;草稿预览不再给一个null分组;ObjectQL 面不再绕过 include 去 join。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 privateassertDimensionJoinsBacked). For eachdimensionsentry that resolves to no declared dimension (declaredMemberEntry, the strategies' ownlookupMembermirror) and is a dotted identifier path, it walks the path hop by hop withunbackedRelationshipPath(new export ofdataset-compiler.ts, besidejoinAlias; not re-exported from the package entry) against the compiled dataset'sallowedRelationships, the set the native join allowlist reads. The first undeclared path is refused throughinvalidMemberError(dataset-refusal.ts), the member-levelINVALID_FIELD/ 400 constructor the other member refusals use, carryingmember,param: 'dimensions'andcube:It is asked in two places, one message, no copy:
callCtx(shared byquery()andgenerateSql(), 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.answerDataset, at the same point: afterassertFieldsReadable, before the evaluator.Why after the admission verdicts, not in
ensureCube. The first cut asked it inensureCubebeside 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 aftercallCtx's admission keeps that 403 (the same reason the order-key door sits there).Scope kept to the ruling.
dimensions. A dottedtimeDimensionsentry already answers oneINVALID_FIELDon both live strategies (measured below), so it is left alone.alloweddefined). A hand-authored cube's configuredgetAllowedRelationshipshook still reaches the native strategy'sDATASET_INVALID.native-sql-strategy.ts) is untouched; it still answersDATASET_INVALIDfor aruntimeFiltermember through an undeclared path, and for every other reason it is raised today.dataset-compiler.ts's ownassertDeclaredis 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 dotteddimensionsentry as its trigger. That is exactly this card's case, now refused ahead of the strategy, so the row's trigger becomes aruntimeFiltermember ({ 'account.region': 'emea' }) that still reaches the strategy allowlist: its audit trail (the producer'sDATASET_INVALIDenvelope,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: realObjectQL+SqlDriver(better-sqlite3, in memory) throughAnalyticsServicePlugin.init, the native face, the ObjectQL face and the preview face (a pending seed draft), the #28201 harness.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, noinclude),account.owner.name(onlyaccountincluded, refused namingaccount.owner), andaccount.namebeside the declaredregion.account.nameserves Acme 130 / Globex 50 on both live strategies and is not refused on the preview;os28258_deal.region(resolves to the declaredregion) serves amer 150 / emea 30 on both live strategies and is not refused on the preview; undeclared plainboguskeeps 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;nopebesideaccount.nameis answered about the measure on every face; aruntimeFiltermemberaccount.namekeeps the nativeDATASET_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:
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'")[{ "os28258_deal.bogus": null, total: 180 }]account.name(no include)DATASET_INVALID/ 400nullgroup, 180account.name(include account)nullgroup, 180os28258_deal.region(no include)nullgroup, 180Green, ablation, suites
preview-undeclared-dimension-parity,preview-filter-field-json-dimension-parity,preview-undeclared-measure-parity) 37/37.node scripts/ablation-replace.mjs --delete, anchor the preview'sthis.assertDimensionJoinsBacked(q, cube, compiled.allowedRelationships);, i.e. thenull-group behaviour put back): anchor 1 → 0, blob9b761f324397→5c8914397b7f;Tests 4 failed | 5 passed (9), the four refusal pins red (the preview resolved), controls green; restore blob equals HEAD,git diff HEADempty. The suite imports the service fromsrc/, 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
DATASET_INVALIDfor this case (measured before changing it, per the order):@object-ui/data-objectstack'squeryDatasetadapter is the only runtime reader, and it sends every named non-permission code to one generic "Dataset query failed: 400 — detail" error (branch ⑤), soINVALID_FIELDlands identically; its two tests use the code as fixture text only. No reader inpackages/client, the console, HotCRM (its oneDATASET_INVALIDmention is a currency comment),examples/,skills/**orcontent/docsnames this case. In-repo test pins: the rest refusal-envelope row (moved, above); service-analyticsdataset-refusal-envelope.test.ts⑤ andnative-sql-rls.test.tscall the strategy directly and are unchanged;dataset-rls-integration.test.tsuses a declared dimension under a configured hook and is unchanged. No real consumer, so both halves land.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 incallCtx, the same narrowing reaches/analytics/queryand/analytics/sqlover 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).cubeObjectsonly), 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.eeec88d14, not fixed here (in the report for the seat):account.namewithinclude: ['account']) and a dotted spelling that resolves to a declared dimension (OBJECT.region,CUBE.region) serve onenullgroup on the preview where both live faces serve real groups.timeDimensionsentry (account.created_at, no include): both live facesINVALID_FIELD/ 400; the preview serves[{ total: 180 }], dropping the time dimension.runtimeFiltermember through an undeclared path (account.name): nativeDATASET_INVALID, ObjectQLINVALID_FIELD("cannot evaluate a cross-object filter"), preview[].account.bogus, include account): nativeDATABASE_ERROR/ 500, ObjectQLINVALID_FIELD/ 400, preview onenullgroup.Verification
Readings on
9b932230a(code head: the union, the dogfood battery and the full build ran on it). Head6001927adadds one commit that only respells two placeholders in the changeset prose (OBJECT.bogusfor 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. Head0fb1c3aa9adds one more prose-only commit (the changeset's Clause-② sentence and "Who sees it" name/analytics/queryand/analytics/sql, per the contract review); the same eight families re-run there, all exit 0 (first reads exit 3 in a worktree withoutnode_modules, re-read afterpnpm install), and the derived list is again identical.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 62 families, each run with< /dev/nulland its exit code captured before any pipe;--ranreconciliation:✓ 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-loadsexit 3 ("Runpnpm buildfirst") → fullpnpm build --concurrency=2(117 tasks, exit 0) → exit 0.check:type-check-debtexit 0 (0 raw tsc error(s) total, none above its recorded number).node scripts/check-platform-checklist.mjs --changed-from origin/mainexit 0;node scripts/check-commit-card-trailers.mjs --range origin/main..HEADexit 0;pnpm exec eslint --no-inline-config packages/services/service-analytics/src packages/rest/src/analytics-dataset-refusal-envelope.test.tsexit 0, no output (neither package has alintscript; the repo-widepnpm lintis CI's).analytics-service.ts,dataset-compiler.tsor the rest refusal-envelope test.!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.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5pG4c8xoSCyPjcztJc
Generated by Claude Code