`classifyAnalyticsFailure` only recognized 400 `VALIDATION_FAILED` (or a
code-less 400) as a refusal of our query body. A 400 carrying any OTHER
code -- service-analytics ships its own 400 `INVALID_FILTER` on a filter
shape it refuses -- matched none of the branches and fell through to
`unknown`, which `aggregate()`'s catch has no arm for, so it silently
degraded to `aggregateViaFind`: a re-read through `find()`'s `$filter`
query-string contract, a different door that accepts array shapes the
analytics body does not. A filter the analytics route refused could still
render a plausible, wrong number with no sign the request had a defect.
Add a floor beneath the four existing code branches (objectui#5721):
`status === 400` now implies `rejected` regardless of whether a code is
present, placed after CUBE_NOT_FOUND and before the code-less residual so
none of the four established code branches are reordered or shadowed.
Whether an unmatched non-400 coded error should keep degrading is an
explicit non-goal here (ruled out of scope) -- it is unaffected.
Pinned with a coded-400 INVALID_FILTER case: reaches the caller as
AnalyticsQueryRejectedError and never reaches aggregateViaFind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Fixes #7755
What
classifyAnalyticsFailure(packages/data-objectstack/src/index.ts) only recognized 400VALIDATION_FAILED(or a code-less 400) as a refusal of our own query body. A 400 carrying any OTHER code —service-analyticsships its own 400INVALID_FILTERon a filter shape it refuses — matched none of the four code branches, fell through tounknown, andaggregate()'s catch has nounknownarm, so it silently degraded toaggregateViaFind: a re-read throughfind()'s$filterquery-string contract, a different door that accepts array shapes the analytics POST body does not. A filter the analytics route refused could still render a plausible, wrong number with no sign the request had a defect.Adjudication (binding, from the PM dispatch on #7755):
status === 400impliesrejectedregardless of whether a code is present. The four existing code branches (NOT_IMPLEMENTED/ROUTE_NOT_FOUND→not-installed,VALIDATION_FAILED→rejected,UNAUTHENTICATED,CUBE_NOT_FOUND) are neither removed nor reordered — this is a floor added beneath them. An unmatched non-400 coded error keeps degrading, unchanged; that question was explicitly ruled out of this card's scope.Change
classifyAnalyticsFailure: addedif (status === 400) return { kind: 'rejected', ... }immediately after theCUBE_NOT_FOUNDbranch and before the code-less residual, and removed the now-redundant/unreachablestatus === 400check that lived inside thecode === undefinedgate (the new floor already covers that case). Extended the existingrejected-outcome doc comment in place (per the PM's suggested route) rather than adding a second explanation.aggregate-capability.test.ts: extended the module doc table and added a pin — a coded 400 (INVALID_FILTER, the real codeservice-analyticsships for a refused filter shape, chosen over a made-up code) reaches the caller asAnalyticsQueryRejectedError(withserverCodepreserved) and never triggers a/api/v1/datafallback call.Mechanism assumptions — measured, not assumed
find()'s fallback path (aggregateViaFind→this.find(resource, { $filter: params.filter })) is a genuinely different door than the analytics POST body —find()routes throughclient.data.find()→ objectql's$filterquery-string contract, whileaggregate()'s analytics path posts throughlowerAnalyticsFilterForWireintoclient.analytics.query(). These are different parsers with different accepted shapes (documented in the surrounding code re:element:numberarray-only shapes, objectstack#12039 / objectui#7752).classifyAnalyticsFailureclassifies a coded 400 other thanVALIDATION_FAILEDasunknown, soaggregate()answers a server refusal with client-side numbers #7755 (os-zhuang) independently verified, from the siblingobjectstackrepo tree, that/analytics/queryrelays bothcodeandstatusverbatim (dispatcher-plugin.errorResponseBase, pinned inanalytics-query-read-scope-withhold.test.ts) and thatservice-analyticsdoes ship400 INVALID_FILTER(spec/src/api/errors.zod.tsstandard catalog, multiple pins across the client/core packages) — so this is live, not merely latent.Gates (run from repo root per this repo's vitest-invocation discipline)
pnpm exec vitest run packages/data-objectstack/src/aggregate-capability.test.ts— 13/13 passed.pnpm exec vitest run packages/data-objectstack/— 56 test files, 749 tests, all passed.pnpm --filter @object-ui/data-objectstack type-check— clean, 0 errors (after building the@object-ui/types/@object-ui/coredependency closure first).node scripts/check-control-bytes.mjs— OK (6472 scanned, 85 skipped-binary).node scripts/check-changeset-presence.mjs— OK, 1 changeset declared for the 2 changed source files.node scripts/check-changeset-no-major.mjs— OK, nomajorbump declared.check:published-dist/check:published-tsconfig-exclude— not run locally (both rebuild the full workspace, ~30+ packages). This PR's diff touches nopackage.jsonpublish-contract field and notsconfig*.jsonfordata-objectstack(verified:git diff origin/main -- packages/data-objectstack/package.json packages/data-objectstack/tsconfig*.jsonis empty) — both gates are structurally invariant to this diff. Deferred to CI.Changeset
.changeset/analytics-coded-400-rejected.md—@object-ui/data-objectstack: minor (matches the bump used by the closest precedent,analytics-failure-code-first.mdfor objectui#5721, which changed the same function's throw-vs-degrade behavior the same way).🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code