Skip to content

fix(service-analytics)!: a caller-named measure whose source names no field is refused at the mint, INVALID_FIELD / 400 (#21437) - #21474

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21437-empty-prefix-measure
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21437-empty-prefix-measure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21437
Clause-②: no (narrowing)

Dispatched by the PM claim 5961286080 (PM loop round 2, domain:services seat 2), on triage's grade 5957198328 as amended by 5958146715. Session session_01DiCSbmJrkzNhuEAier4VoJ.

inferMeasure minted the row wildcard '*' for an empty prefix (_sum became { type: 'sum', sql: '*' }), and it passed *, *_sum and the empty spelling through verbatim. The member-shape gate admits '*' as a column reference, so SUM(*) / AVG(*) / COUNT(DISTINCT *) / SUM() reached the database and POST /api/v1/analytics/query answered 500 DATABASE_ERROR on both strategies. The mint now admits '*' only for the bare count. It refuses a source that names no field with INVALID_FIELD / 400, naming the spelling the caller sent, before any statement is built.

What changes (@objectstack/service-analytics, src/analytics-service.ts only)

  • INFERRED_MEASURE_SUFFIXES. The suffix list inferMeasure iterates 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 but count, 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.
  • The envelope. The refusal is built by invalidMemberError, the constructor the mint's dotted-measure refusal already uses. It carries code: INVALID_FIELD, status: 400, member (the spelling as sent, with any CUBE. qualifier), param: 'measures' and cube. That is the code and status of the missing-field refusal (assertMeasureFields). There is no field, because there is no field to name.
  • Both mint sites pass the request's own entry. They are the ad-hoc mint in inferCubeFromQuery and the augmentation loop in ensureCube. Both run inside ensureCube, ahead of assertCallerMembersResolvable and of strategy selection, on query() and on the dry run generateSql().
  • inferredCallerMeasureSql, the gate's input, is exported from the module only, for the by-construction pin.
  • Unchanged, per the amended ruling: the gate's '*' pass-through. There is no local copy of rowWildcardOutsideCount, no spec export, no strategy edit and no filter-normalizer.ts edit (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/query

The probe used the runtime dispatcher's composition from packages/runtime/src/analytics-json-dimension-door.test.ts: AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver (SQLite), with the dispatcher-plugin route. The ObjectQL cells narrow queryCapabilities to the engine-aggregate path. There are three rows, amounts 100 / 300 / 1000. The authored cube declares count and amount_total only. The probe file was throwaway and is not committed.

spelling origin/main 713b0fa76: native SQL 713b0fa76: ObjectQL this branch: both strategies
_sum, _avg, _average, _min, _max (ad hoc and authored, bare and CUBE.-qualified) 500 DATABASE_ERROR, rawSql+1 500 DATABASE_ERROR, aggregate+1 400 INVALID_FIELD, no read
_count_distinct 500 DATABASE_ERROR, rawSql+1 400 INVALID_QUERY, aggregate+1 400 INVALID_FIELD, no read
*, *_sum, *_avg, CUBE.* 500 DATABASE_ERROR, rawSql+1 500 DATABASE_ERROR, aggregate+1 400 INVALID_FIELD, no read
*_count_distinct 500 DATABASE_ERROR, rawSql+1 400 INVALID_QUERY, aggregate+1 400 INVALID_FIELD, no read
'' (empty spelling) 500 DATABASE_ERROR, rawSql+1 500 DATABASE_ERROR, aggregate+1 400 INVALID_FIELD, no read
CUBE. (qualifier alone) 403 PERMISSION_DENIED, no read 403 PERMISSION_DENIED, no read 400 INVALID_FIELD, no read
control count 200, 3 200, 3 200, 3
control amount_sum 200, 1400 200, 1400 200, 1400
control amount_total (authored) 200, 1400 200, 1400 200, 1400
_, __sum (prefix _ is a field-shaped name) 400 INVALID_FIELD (missing field _) same unchanged

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 with count") cannot hold unless the mint also refuses a '*' source: * and *_sum mint '*' under sum, 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-count aggregate) and needs no second rule.

  • The empty source with no suffix ('', and CUBE. after the qualifier strip). These are bounded in-place fixes, and all four conditions hold.

    1. It is the same defect class: a caller spelling minted into an aggregate over a source that names no field. '' answered 500 on main.
    2. The fix is mechanical: the same source === '' condition.
    3. The file is held by no other claim (the claim's file surface).
    4. It is the same pin file and gate family, with no new verification surface.

    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. CubeSchema admits any measure key (z.record(z.string(), MetricSchema)). Measured at the spec dist built from 713b0fa76: a cube declaring measures _sum and * parses. DatasetSchema refuses a dataset measure named _sum (invalid_format, snake_case starting with a letter). A cube that DECLARES _sum hits 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 ObjectQL engine and SqlDriver (SQLite), both auto-bridges live, on the native and ObjectQL strategies. They follow the precedent of cube-measure-field-type-door.test.ts.

  • Enumeration. Every suffix in INFERRED_MEASURE_SUFFIXES is tested with an empty prefix, bare and CUBE.-qualified, on both strategies, on an ad-hoc cube and on an authored cube that does not declare it. Each is refused with INVALID_FIELD / 400, member equal to the spelling, param: 'measures', cube, no field, and the message naming the spelling. Each run has zero raw statements and zero engine aggregates.
  • Other no-field sources. *, * with every suffix, '', CUBE.* and CUBE. get the same refusal, with no read.
  • Controls. The bare count returns 3, and its dry-run statement is COUNT(*). amount_sum returns 1400 (ad hoc and authored). The authored amount_total returns 1400. A declared _sum member is served, 1400.
  • Dry-run door. generateSql refuses every empty-prefix suffix the same way.
  • By construction (the added pin). Over a generated corpus (qualifier '' / CUBE. / other. × prefix '', *, **, a space, _, amount, count × tail '' or each suffix), inferredCallerMeasureSql either refuses with the envelope or returns null or a string. A '*' comes back only for count and CUBE.count, which inferMeasure types count. 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/query and /analytics/sql routes call one-to-one. The dispatcher carries a thrown code / status to the wire, and the same INVALID_FIELD / 400 crossing is already pinned at the route by packages/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.mjs in wrap mode, restore proven: blob equals HEAD and git diff HEAD is empty)

The subject is imported from src (../analytics-service.js), so no dist sits on the path.

  • A1: the predecessor mint restored. The throw is replaced with 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 answer DATABASE_ERROR again (and INVALID_QUERY for _count_distinct on ObjectQL), the dry run resolves a statement, and the by-construction pin reports that '' reaches the gate as '*' but is not count.
  • A2: only the wildcard arm dropped. The condition source === '' || source === '*' becomes source === ''. 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 merging origin/main 49524f690)

  • pnpm --filter @objectstack/service-analytics typecheck: clean. tsc --listFiles includes 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 --commands derived 64 commands, and all 64 were run. 63 exited 0 on their first run. pnpm check:dual-build-cjs-loads first exited 3 with PREREQUISITE NOT MET (some packages had no dist/). After a full turbo run build (72 tasks, 71 cache hits) it exited 0. --ran with every exit code recorded: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
  • check:adr-0087-registration judged the changeset [BREAKING+bang+clause-②-narrowing], not-required (no-migration-prescription). check:changeset-no-major reports no major, and check:empty-changeset reports no empty-frontmatter changeset and no modified one.
  • Lint, narrowed and declared as such. eslint --no-inline-config --format json over the two touched .ts files reports 2 files, 0 errors and 0 warnings. eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.
  • CI's own jobs (Test Core shards, Dogfood, Build Core, the type-check lanes) were not measured locally.

Changeset

.changeset/21437-analytics-measure-names-no-field.md declares @objectstack/service-analytics: minor with the BREAKING banner, the Clause-②: 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 same minor-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 the no-migration-prescription disposition, 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 bare count, or one of the object's own field names plus a suffix. The change makes no sentence there false, so it is untouched. A skills/** grep for measure spellings finds only field-prefixed ones (amount_sum, total_sum, revenue_sum). None is an empty prefix or '*'.

Acceptance notes

  • Docs drift, not caused here. inferMeasure also strips _average, and with no suffix it sums the whole key. data-api.mdx lists neither. Nothing was made false, so this is noted only (carrier: none).
  • The console adapter. objectui's analytics adapter, at the pinned .objectui-sha 89cad75d5, 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 as unknown and answered with its client-side aggregateViaFind. It classifies the new 400 as rejected and throws AnalyticsQueryRejectedError. 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.
  • The gate's empty-measure skip. The measure === '' skip in assertCallerMembersResolvable no longer sees '', because the mint refuses it first. It is left as is, and the gate is untouched per the ruling.
  • No dialect cell. The pin file has no PostgreSQL cell. The refusal happens before any statement, so it is dialect-free.

Generated by Claude Code

claude added 4 commits October 2, 2026 21:13
… 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>
@github-actions github-actions Bot added the size/m label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/natural-language-queries.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/data-modeling/queries.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/deployment/validating-metadata.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/kernel/contracts/data-engine.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/ui/dashboards.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))

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

  • content/docs/releases/v15.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))
  • content/docs/releases/v17/17-6.mdx (via count_distinct (literal, a string literal in INFERRED_MEASURE_SUFFIXES; a string literal in inferMeasure; a string literal on a changed line))

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 54 of 206 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 aa4632235ba571ef800b95e6bc18d00a30aa1d57 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 65796aedbbbd7fc8ae71407d951a4d8570483cf1 — the merge of head b49aba4558207b36b3d259f807bf3d45747a8c23 into base aa4632235ba571ef800b95e6bc18d00a30aa1d57, 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 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

⚠️ 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 aa4632235ba571ef800b95e6bc18d00a30aa1d57 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 22:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 22:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 0b82391 Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21437-empty-prefix-measure branch October 2, 2026 22:52
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

1 participant