Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): refuse custom-SQL measures on the ObjectQL aggregate path - #12318
Conversation
…gregate path A measure whose AggregationMetricType is number/string/boolean declares a raw SQL expression as its whole computation. ObjectQLStrategy.resolveMeasureAggregation forwarded the metric type verbatim as the engine method with the expression in field, so driver-sql threw INVALID_QUERY blaming a function key the author never wrote, and the in-memory evaluator answered null for every bucket through its switch default. The prior fix for this class landed on NativeSQLStrategy only, and its regression pin forces objectqlAggregate: false, so it covered one strategy of two. The ObjectQL path now refuses such a measure with INVALID_FIELD / 400 (ADR-0112 via invalidMemberError), naming the measure the author wrote and its metric type, in the posture of the in-file cross-object refusal twin. The arm sits in the one resolver both doors call, so /analytics/query and /analytics/sql accept/reject the same set by construction. It is keyed on the declared EXPRESSION_METRIC_TYPES partition (one source shared with NativeSQLStrategy), deliberately not on a method allowlist: an enum-invalid drift type stays the platform's own undeclared-500 tier instead of being re-blamed on the caller. New pin drives one fixture through BOTH strategies: refusal envelope on the ObjectQL profile, expressions still served verbatim on the native profile, all six admitted aggregates still reaching the engine carrying their own methods, and the cross-object non-recombinable refusal keeping its exact message. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 8 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 b74f793e02542ff35965c7b6cb9946d865936b79 && git checkout b74f793e02542ff35965c7b6cb9946d865936b79
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 783111d25227b7f907f2a0059797056e0fb78e8a f3167152522947164e6f8aa0bab198d1f121cced && git checkout -B drift-repro 783111d25227b7f907f2a0059797056e0fb78e8a && git merge --no-ff f3167152522947164e6f8aa0bab198d1f121cced
node scripts/docs-audit/affected-docs.mjs --json 783111d25227b7f907f2a0059797056e0fb78e8a |
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#12209)Reviewed by the contract-review chain at Reviewed: the refusal arm and partition-export patches in full, the pin design with its dissolution record, and the lineage on #12209 (the #12053 probe, the #11833 no-type-widening constraint, the #4157 recurrence history). Conformance: the arm sits in the one resolver both doors call (#10759's invariant held by construction), throws The two judgment calls the seat put to this tier, both ratified:
Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12209
What
An authored custom-SQL measure (
AggregationMetricTypenumber/string/boolean) used to reachengine.aggregateun-refused on the ObjectQL path:resolveMeasureAggregationforwarded the metric type verbatim as the engine method with the whole SQL expression infield, sodriver-sqlthrewINVALID_QUERYblaming afunctionkey the author never wrote, and the in-memory evaluator answered a silent per-bucketnullunder the author's own metric name — the #4157 class in its null variant, measured by #12053's probe (an admittedsumreturned 300 per bucket where the custom-SQL measure returnednull).This PR adds the
INVALID_FIELD/ 400 refusal arm (ADR-0112, viainvalidMemberError) on the direct path ofObjectQLStrategy, naming the measure the author wrote and its metric type, in the posture of the in-file cross-object twin (ending "…or run on a native-SQL driver"). Option B (lowering the expression through the aggregate AST) is apackages/speccontract question and is deliberately NOT here; per the maintainer's #11833 ruling this is also not closed by widening any local type — no type was touched.Where the arm sits, and why
Inside
resolveMeasureAggregation's declared-measure branch — the one resolver both doors (execute()andgenerateSql()) call — so/analytics/queryand/analytics/sqlaccept/reject the same set by construction (#10759's invariant). Two deliberate consequences, both pinned:EXPRESSION_METRIC_TYPESpartition (ONE source, imported fromNativeSQLStrategy, pinned against the spec enum bymetric-type-coverage.test.ts), deliberately NOT on a method allowlist. The two read identically on every enum-valid cube; they differ on enum-INVALID drift (e.g. a host-registered cube with typemedian), which stays the platform's own undeclared-500 tier perdataset-refusal.ts's header instead of being re-blamed on the caller as a 400. A pinned case makes a method-allowlist implementation go red.The pins — one fixture, BOTH strategies
The defect existed because
NativeSQLStrategy's regression pinmeasure-expression-sql.test.tsforcesobjectqlAggregate: false, covering one strategy of two. The new pinmeasure-expression-both-strategies.test.tsis a shared table: one cube (all six aggregate types + all three expression types + one enum-invalid drift type) driven through the realAnalyticsServicerouting under both capability profiles, so neither strategy's fixture can hide the other:codeINVALID_FIELDandstatus400,member= the measure as the author wrote it,parammeasures,cubenamed, the message naming the measure and metric type — and the engine never reached (callsandsqlsboth empty). Also on the scalar shape and in a mixed query.summeasure is still served and still reaches the engine carrying{ field: 'amount', method: 'sum' }; all six aggregates likewise, each carrying its own method. Plus the drift-tier case above — the case that reds a "refuse every method that is not one of the six aggregates" implementation, which passes pin 1.Dissolution verification (direction predicted in writing before running)
Predicted: restoring the accepting behaviour (strategy file back at base
5ce5f8c12) turns exactly the six ObjectQL-profile refusal cases red — no error, engine reached — and leaves the other six green. Measured exactly that:Mutation was confirmed on disk before reading results (grep of the throw anchor: 1 → 0 after
git restore --source=5ce5f8c12; the pin runs the package's own source via relative imports, no dist in the resolution path). Restored viagit checkout HEAD -- the strategy file; disk == index == HEAD verified (empty porcelain, emptydiff HEAD, byte-diff againstHEAD:and:both clean).Verification at final commit
f31671525pnpm --filter @objectstack/service-analytics test— 81 files, 1790 passed (run atf31671525after the final commit).pnpm --filter @objectstack/service-analytics build(incl. DTS) — clean.tsc --noEmiterror count is exactly the type-check DEBT ledger's frozen 10, none in the new test file (measured directly, the way the ledger measures; the workspace-widecheck:type-check-debtsweep is CI's).scripts/pm/dispatch-gates.mjs, no paths — changed-set self-derived):check:nul-bytes,check:changeset-gate-self-tests,check:objectui-changeset,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:engine-double-contract,check:cross-package-test-inputs,check:where-matcher,check:type-check-coverage,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,docs-audit/check-affected-docs,docs-audit/check-drift-comment,release-rehearsal-clone --self-test— all exit 0 (exit codes captured before any pipe).Changeset
@objectstack/service-analyticspatch: states plainly what stops being served and for whom — on ObjectQL-path deployments a custom-SQL measure now answers a loud 400 instead of a 200 full of per-bucket nulls; native-SQL behaviour unchanged. No authorable key removed, no ADR-0087 disposition required (not declared-breaking).This PR is DRAFT and carries
needs:contract-review(it changes contract accept/reject behaviour on an authorable surface); it awaits the contract-review chain — do not flip ready or arm auto-merge.Generated by Claude Code