fix(service-analytics)!: a caller-named measure whose source names no field is refused at the mint, INVALID_FIELD / 400 (#21437) - #21474
Conversation
… field is refused at the mint, INVALID_FIELD / 400 inferMeasure minted the row wildcard for an empty prefix (`_sum` became SUM(*)) and passed `*`, `*_sum` and the empty spelling through verbatim, so POST /api/v1/analytics/query answered 500 DATABASE_ERROR on both strategies. The mint now admits the row wildcard only for the bare `count` and refuses a source that is empty or `*`, naming the spelling the caller sent, before any statement is built. The suffix list is hoisted to INFERRED_MEASURE_SUFFIXES, which the enumeration pin iterates. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…wing of service-analytics Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…>.` measure spelling Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…pty-prefix-measure
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also 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 65796aedbbbd7fc8ae71407d951a4d8570483cf1 && git checkout 65796aedbbbd7fc8ae71407d951a4d8570483cf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa4632235ba571ef800b95e6bc18d00a30aa1d57 b49aba4558207b36b3d259f807bf3d45747a8c23 && git checkout -B drift-repro aa4632235ba571ef800b95e6bc18d00a30aa1d57 && git merge --no-ff b49aba4558207b36b3d259f807bf3d45747a8c23
node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57
|
Fixes #21437
Clause-②: no (narrowing)
Dispatched by the PM claim
5961286080(PM loop round 2,domain:servicesseat 2), on triage's grade5957198328as amended by5958146715. Sessionsession_01DiCSbmJrkzNhuEAier4VoJ.inferMeasureminted the row wildcard'*'for an empty prefix (_sumbecame{ type: 'sum', sql: '*' }), and it passed*,*_sumand the empty spelling through verbatim. The member-shape gate admits'*'as a column reference, soSUM(*)/AVG(*)/COUNT(DISTINCT *)/SUM()reached the database andPOST /api/v1/analytics/queryanswered500 DATABASE_ERRORon both strategies. The mint now admits'*'only for the barecount. It refuses a source that names no field withINVALID_FIELD/ 400, naming the spelling the caller sent, before any statement is built.What changes (
@objectstack/service-analytics,src/analytics-service.tsonly)INFERRED_MEASURE_SUFFIXES. The suffix listinferMeasureiterates is hoisted to one module-level constant. It is exported from the module only, not from the package index. The enumeration pin iterates it, so a suffix added later is pinned when it lands.inferMeasure(key, spelling). For any key butcount, the source is the part before the matched suffix, or the whole key when no suffix matches. A source that is empty or'*'is refused. No other path changes: a non-empty source is minted exactly as before.invalidMemberError, the constructor the mint's dotted-measure refusal already uses. It carriescode: INVALID_FIELD,status: 400,member(the spelling as sent, with anyCUBE.qualifier),param: 'measures'andcube. That is the code and status of the missing-field refusal (assertMeasureFields). There is nofield, because there is no field to name.inferCubeFromQueryand the augmentation loop inensureCube. Both run insideensureCube, ahead ofassertCallerMembersResolvableand of strategy selection, onquery()and on the dry rungenerateSql().inferredCallerMeasureSql, the gate's input, is exported from the module only, for the by-construction pin.'*'pass-through. There is no local copy ofrowWildcardOutsideCount, no spec export, no strategy edit and nofilter-normalizer.tsedit (the file analytics: a list comparand at a scalar operator ({ amount: { $gt: [10, 99] } }) answers 200 bound to its first member on the engine-aggregate face (and on both faces for a text column), where the spec's verdict refuses the list form #21448 holds).Measured at the door:
POST /api/v1/analytics/queryThe probe used the runtime dispatcher's composition from
packages/runtime/src/analytics-json-dimension-door.test.ts:AnalyticsServicePluginover a realObjectQLengine andSqlDriver(SQLite), with the dispatcher-plugin route. The ObjectQL cells narrowqueryCapabilitiesto the engine-aggregate path. There are three rows, amounts 100 / 300 / 1000. The authored cube declarescountandamount_totalonly. The probe file was throwaway and is not committed.origin/main713b0fa76: native SQL713b0fa76: ObjectQL_sum,_avg,_average,_min,_max(ad hoc and authored, bare andCUBE.-qualified)DATABASE_ERROR, rawSql+1DATABASE_ERROR, aggregate+1INVALID_FIELD, no read_count_distinctDATABASE_ERROR, rawSql+1INVALID_QUERY, aggregate+1INVALID_FIELD, no read*,*_sum,*_avg,CUBE.*DATABASE_ERROR, rawSql+1DATABASE_ERROR, aggregate+1INVALID_FIELD, no read*_count_distinctDATABASE_ERROR, rawSql+1INVALID_QUERY, aggregate+1INVALID_FIELD, no read''(empty spelling)DATABASE_ERROR, rawSql+1DATABASE_ERROR, aggregate+1INVALID_FIELD, no readCUBE.(qualifier alone)PERMISSION_DENIED, no readPERMISSION_DENIED, no readINVALID_FIELD, no readcountamount_sumamount_total(authored)_,__sum(prefix_is a field-shaped name)INVALID_FIELD(missing field_)Every refused cell's message names the spelling sent, for example "Measure '_sum' on cube 'X' names no field to aggregate: nothing precedes the suffix '_sum'".
Scope: one rule, wider than the card's title (declared)
The row wildcard source (
*,*_SUFFIX,CUBE.*). The card names the empty prefix. The added pin ("for every caller-named spelling, a'*'reaches the gate only together withcount") cannot hold unless the mint also refuses a'*'source:*and*_summint'*'undersum, verbatim. That was measured as 500 above. One condition, source'*', covers it. This is the card's own family (the row wildcard under a non-countaggregate) and needs no second rule.The empty source with no suffix (
'', andCUBE.after the qualifier strip). These are bounded in-place fixes, and all four conditions hold.''answered 500 onmain.source === ''condition.Moved answer, declared in the changeset:
CUBE.was a 403 from the member-shape gate and is now the mint's 400, since it names no field either.Authored members are not this card's.
CubeSchemaadmits any measure key (z.record(z.string(), MetricSchema)). Measured at the specdistbuilt from713b0fa76: a cube declaring measures_sumand*parses.DatasetSchemarefuses a dataset measure named_sum(invalid_format, snake_case starting with a letter). A cube that DECLARES_sumhits its own member and never reaches the mint. A pin serves it (1400).Pins:
src/__tests__/caller-measure-no-field-door.test.ts(new)The pins use the plugin's own composition over a real
ObjectQLengine andSqlDriver(SQLite), both auto-bridges live, on the native and ObjectQL strategies. They follow the precedent ofcube-measure-field-type-door.test.ts.INFERRED_MEASURE_SUFFIXESis tested with an empty prefix, bare andCUBE.-qualified, on both strategies, on an ad-hoc cube and on an authored cube that does not declare it. Each is refused withINVALID_FIELD/ 400,memberequal to the spelling,param: 'measures',cube, nofield, and the message naming the spelling. Each run has zero raw statements and zero engine aggregates.*,*with every suffix,'',CUBE.*andCUBE.get the same refusal, with no read.countreturns 3, and its dry-run statement isCOUNT(*).amount_sumreturns 1400 (ad hoc and authored). The authoredamount_totalreturns 1400. A declared_summember is served, 1400.generateSqlrefuses every empty-prefix suffix the same way.''/CUBE./other.× prefix'',*,**, a space,_,amount,count× tail''or each suffix),inferredCallerMeasureSqleither refuses with the envelope or returns null or a string. A'*'comes back only forcountandCUBE.count, whichinferMeasuretypescount. The pin also asserts that the corpus exercised both arms.The pins stay in
service-analytics.AnalyticsService.query()/generateSql()are what the dispatcher's/analytics/queryand/analytics/sqlroutes call one-to-one. The dispatcher carries a throwncode/statusto the wire, and the sameINVALID_FIELD/ 400 crossing is already pinned at the route bypackages/runtime/src/analytics-json-dimension-door.test.ts. The throwaway probe above measured the wire answers on this branch. No runtime file is added, so there is no cross-lane addition.Ablations (from the committed state,
node scripts/ablation-replace.mjsin wrap mode, restore proven: blob equals HEAD andgit diff HEADis empty)The subject is imported from
src(../analytics-service.js), so nodistsits on the path.return { label: key, type, sql: source || '*' };. Predicted: the 8 refusal tests, the 2 dry-run tests and the by-construction pin go red, and the 9 controls stay green. Observed: 11 failed, 9 passed. The enumeration cells answerDATABASE_ERRORagain (andINVALID_QUERYfor_count_distincton ObjectQL), the dry run resolves a statement, and the by-construction pin reports that''reaches the gate as'*'but is notcount.source === '' || source === '*'becomessource === ''. Predicted: the empty-prefix enumeration and the dry run stay green, and the 4 other-no-field tests and the by-construction pin go red. Observed: 5 failed, 15 passed, with "*reaches the gate as'*'but is not count". So the added pin catches what the empty-prefix enumeration alone does not.Verification (at
b49aba455, after mergingorigin/main49524f690)pnpm --filter @objectstack/service-analytics typecheck: clean.tsc --listFilesincludes the new test file.pnpm --filter @objectstack/service-analytics test(the full script): 172 files passed, 3931 tests passed, 183 skipped.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 64 commands, and all 64 were run. 63 exited 0 on their first run.pnpm check:dual-build-cjs-loadsfirst exited 3 withPREREQUISITE NOT MET(some packages had nodist/). After a fullturbo run build(72 tasks, 71 cache hits) it exited 0.--ranwith every exit code recorded: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".check:adr-0087-registrationjudged the changeset[BREAKING+bang+clause-②-narrowing],not-required (no-migration-prescription).check:changeset-no-majorreports nomajor, andcheck:empty-changesetreports no empty-frontmatter changeset and no modified one.--no-inline-config --format jsonover the two touched.tsfiles reports 2 files, 0 errors and 0 warnings.eslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Changeset
.changeset/21437-analytics-measure-names-no-field.mddeclares@objectstack/service-analytics:minorwith the BREAKING banner, theClause-②: no (narrowing)line, before and after cells, the one-line fix, and one ADR-0087 disposition (not-required (no-migration-prescription), every other category ruled out on facts). Its twin precedent is #21431's.changeset/21409-analytics-row-wildcard-count-only.md, the authored-position half of the same rule. It is the sameminor-under-launch-window shape with the BREAKING banner and the(narrowing)arm. That one registers a D3 entry because stored documents need a prescription. This one has no stored shape, so it takes theno-migration-prescriptiondisposition, as the sibling analytics-door narrowings do (21267-analytics-order-key-selected.md,21426-native-number-comparand.md).Docs
content/docs/api/data-api.mdx("How to spell a measure") already states the contract this enforces: the barecount, or one of the object's own field names plus a suffix. The change makes no sentence there false, so it is untouched. Askills/**grep for measure spellings finds only field-prefixed ones (amount_sum,total_sum,revenue_sum). None is an empty prefix or'*'.Acceptance notes
inferMeasurealso strips_average, and with no suffix it sums the whole key.data-api.mdxlists neither. Nothing was made false, so this is noted only (carrier: none)..objectui-sha89cad75d5, composes a measure as the value field, an underscore and the function. A widget with an empty value field would post_sum. That adapter classified the old 500 asunknownand answered with its client-sideaggregateViaFind. It classifies the new 400 asrejectedand throwsAnalyticsQueryRejectedError. This is the intended direction (loud over plausible numbers), and the changeset states it. Whether any shipped widget reaches an empty value field was not measured.measure === ''skip inassertCallerMembersResolvableno longer sees'', because the mint refuses it first. It is left as is, and the gate is untouched per the ruling.Generated by Claude Code