Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): refuse a cross-object per-measure filter on both ObjectQL doors - #12022
Conversation
…oth ObjectQL doors `ObjectQLStrategy` judges every query by one member view (`filterMemberView`), documented as "two producers, one inventory" (#10861): the caller's `where` and the compiled dataset's definition-level `filter`. #10413 phase 2 added a third producer with the same reach and none of the coverage — a compiled measure's own `filter`, lowered onto that measure's `aggregations[].filter` entry (#10576) — and `planCrossObject`'s `query.measures` arm reads only each measure's resolved FIELD, never its filter. So a cross-object leaf there reached `engine.aggregate` unrefused on both doors. Reproduced first (the card was code-read, not executed), one fixture, an honest in-memory engine that applies `aggregations[].filter` as a property match — all `engine.aggregate` can do, since it cannot join: BEFORE execute() ACCEPTED, engine reached once with {field:"*",method:"count",alias:"west_count", filter:{"account.region":"West"}} and answered west_count 0 where the truth is 2 — beside a correct total_count 3, so the wrong number came back in the same response shape as the right one generateSql() ACCEPTED, rendering COUNT(CASE WHEN account.region = $1 THEN 1 END) over a FROM with no join in it at all AFTER both doors REFUSED, INVALID_FIELD/400, engine never reached Mirrors #10861 exactly, on the same maintainer ruling (2026-08-22, Option A — refuse at query time, folding the leaves into the one member view): the origin becomes a record so provenance can carry the MEASURE the field cannot, only the REQUESTED measures are folded (both aggregation loops read `measureFilters[m]` for `m of query.measures` and nothing else), and insertion order keeps every shape refused before this card on the exact message it already had. Beyond the internal inconsistency this squares the door with a published promise: `content/docs/api/data-api.mdx` documents that a bad field in an `aggregations` entry answers 400 INVALID_FIELD. The same `aggregations` object kept that promise in the `field` position and broke it in the `filter` position — 200 with a silent 0, the exact failure class that page's preamble names as its reason for existing. The page needs no edit: no sentence in it describes the exception, so it is simply true again. Fixes#11461 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 93db1606438c4a54c7f7d60931985a9ccdf68d94 && git checkout 93db1606438c4a54c7f7d60931985a9ccdf68d94
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1620c1de2805ade35890c38470a57c9b4fe3eb70 783657d7b5fb865441df6a18735b6ee764ea49cf && git checkout -B drift-repro 1620c1de2805ade35890c38470a57c9b4fe3eb70 && git merge --no-ff 783657d7b5fb865441df6a18735b6ee764ea49cf
node scripts/docs-audit/affected-docs.mjs --json 1620c1de2805ade35890c38470a57c9b4fe3eb70
|
os-trump
commented
Aug 25, 2026
Contract review PASS at Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11461
⭐ Clause-② declared: this widens a refusal set on a served path
Accept/reject behaviour changes by construction — a query that was served (with a wrong number) now answers
400 INVALID_FIELD.needs:contract-reviewis hung on this PR as well as the card (dual carrier). This PR stays draft; the label is not cleared, ready is not flipped, auto-merge is not armed.What was open
ObjectQLStrategyjudges every query by one member view (filterMemberView), documented as "two producers, one inventory" (#10861): the caller's ownwhere, and the compiled dataset's definition-levelfilter.#10413 phase 2 added a third producer with the same reach and none of the coverage — a compiled measure's own
filter, lowered onto that measure'saggregations[].filterentry (#10576). The view enumerated exactly two origins (type FilterMemberOrigin = 'where' | 'dataset-filter'), andplanCrossObject'squery.measuresarm reads only each measure's resolved field, never its filter. So a cross-object leaf there reachedengine.aggregateunrefused on both doors.Reproduced before it was fixed
The card was explicit that it was code-read, not executed, so this was measured first — one fixture (a dataset with
include: ['account'], a cross-object dimension, and a measure whose ownfilternamesaccount.region), on the ObjectQL door (nativeSql: false), against an honest in-memory engine: one that appliesaggregations[].filteras a property match on the base row, which is allengine.aggregatecan do, because it cannot join.The change
Mirrors #10861 exactly — the ruled precedent for this hazard class (maintainer, 2026-08-22, Option A: refuse at query time, folding the leaves into the one member view), not a fresh design.
FilterMemberOriginbecomes a record so provenance can carry what the key cannot: the view is keyed by resolved field name (account.region), and the actionable locator for a measure filter is the measure whose filter named it. Two measures in one dataset can name the same field and mean two different edits.filterMemberViewfolds each requested measure's filter leaves in under'measure-filter'. Only the requested ones: both doors' aggregation loops readmeasureFilters[m]form of query.measuresand nothing else, so a filter on a measure the query never asks for reaches no engine, and refusing on it would reject a query for a member that was never going to be evaluated.planCrossObjectgains a third arm with the sameINVALID_FIELD/400 envelope as its two neighbours — same physical verdict, this engine has no join.paramis absent for the A cross-object dataset-levelfilterreachesengine.aggregateunrefused on the ObjectQL path — both doors accept what the engine cannot join #10861 reason, and it bites harder here:memberis the cross-object field, somember+param: 'measures'would send a reader to look foraccount.regioninsidemeasures, where it is not and cannot be.cubeplus the message carry the real locator.where, last write wins) means every shape refused before this card keeps the exact message it had, and a member the request also names keeps the caller's own diagnostic.Both doors are closed in one place —
execute()andgenerateSql()each callplanCrossObject(cube, query, filterMemberView(cube, query, ctx)), so the "preview accepts/rejects the same set" invariant is structural rather than restated.⭐ The independent justification: a published refusal promise that did not hold
Beyond the internal three-producer inconsistency,
content/docs/api/data-api.mdx:143-145is a published Request | Result table promising400 INVALID_FIELDfor a bad field in anaggregationsentry. The sameaggregationsobject kept that promise in thefieldposition and broke it in thefilterposition —200with a silent0. That is precisely the failure class the section's own preamble names as its reason for existing: "answer200with something that looked exactly like a served query."The page was hand-read (docs-drift is symbol-anchored, #9192, and cannot see this tension). It needs no edit: no sentence in it describes the exception, so making the behaviour loud simply makes the page true again on this path. It was deliberately not "fixed" by writing the exception into the doc — that would promote a silent wrong answer to a documented feature.
Pins — ⑦ and ⑧ in
crossobject-conjunct-refusal.test.tsSix new directions on one fixture, so the distinctions are structural rather than three fixtures that happen to differ.
revenue(no filter),won_revenue(ordinary filter),west_revenue(cross-object filter) — the last two one measure apart, travelling the identicalmeasureFilters→aggregations[].filterroute.code/status/member/cube, the absentparam,calls == [], and a message naming the measure.aggregations[].filter(a "refuse every measure filter" implementation breaks every conditional aggregate shipping today); and a cross-object filter on a measure this query does not ask for changes nothing (a "judge the wholemeasureFiltersmap" implementation refuses a query for a member it was never going to evaluate, taking every other measure on a dataset down with one unserveable one).wherecontrol on the same cube, and the ordering pin that the caller's ownwherewins the diagnostic when both name the same member.The test harness now captures
options.aggregations, not just the whole-callfilter— this producer never lands in the whole-call filter, so a harness watching onlyoptions.filtercould not have seen the defect at all.Verification — all on the final commit
783657d7b5Ablation — mutation: remove the
...measureLeaves,spread fromfilterMemberView(ablates the load-bearing fold, leaving the new arm with nothing to find). Prediction written before the run: turns red, exactly two failures —execute() refuses…onexpect(execute).toBeInstanceOf(Error), andboth doors agreeon[false,false]receiving[true,true]— with the other 22 passing. Observed exactly that:Tests 2 failed | 22 passed (24),AssertionError: accepted — the measure's own filter was invisible to the envelope check: expected undefined to be an instance of Error, andexpected [ true, true ] to deeply equal [ false, false ].Mutation confirmed on disk by anchored greps, not by an editor exit code: removed text count
0, surviving declaration count1. Restore was armed as atrap … EXIT INT TERMrunninggit checkout HEAD -- <path>(nevergit checkout <ref> --, which stages), and verified byte-identical afterwards: disk / index / HEAD all293e0004bf7dc54ddcfdfe7caaf43c809e63fdb8,git status --porcelainempty. No rebuild was needed for these runs — the tests import../analytics-service.jsfrom source, so nodist/sits in the resolution path.Gates — derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(provenance line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit 783657d"; change set taken by the script itself from the merge based63b01436, three-dot). Every exit code captured before any pipe; each gate quoted by its own verdict line.service-analyticssuiteTest Files 80 passed (80)·Tests 1778 passed (1778)check:engine-double-contractOK — 405 pinned, 133 in the DEBT ledger, 2 exempt.check:where-matcher✓ where-matcher conformance holds: 297 matcher(s) … none new. baseline key set verified against d63b014: no files added.check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none newcheck:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root)check:type-check-debt--re-measure: OK — 32 ledger entr(ies) re-measured in 222.5s, 1898 raw tsc error(s) total, none above its recorded number.check:nul-bytesOK (scanned 6662 text file(s) … no raw ASCII control bytes).check:test-source-aliasOK — 72 packages with tests scannedcheck:type-source-resolutionOK — 93 tsc program(s) across 77 packages scannedcheck:published-files✓ … 69 publishable package(s) … declare a files whitelistcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) … none newcheck:empty-changeset✓ No empty-frontmatter changeset introduced by this diffcheck:changeset-no-major✓ This diff introduces no major bump.check-adr-0087-registration✓ … adds no declared-breaking changesetcheck:changeset-gate-self-tests✓check:objectui-changeset✓ objectui-range --self-test: all checks passedrelease-rehearsal-clone --self-test✓ self-test passedcheck:plugin-teardown-shape✓ … 63 Plugin implementation(s) across 4647 source(s)pnpm lint(whole repo)eslint . --no-inline-config— exit 0, no output; this is the full repo-wide scan, not a narrowingcheck:type-check-debtrefused on its first attempt ("--re-measure cannot run: 47 workspace dependenc(ies) … have no built type entry point on disk"). That throw is NOT MEASURED, never a pass, so the full closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*'—Tasks: 70 successful, 70 total) and the gate re-run to the green above. Every heavy command ran throughscripts/pm/os-verify-lock.sh; each round'sVERDICTline reportscommand-exit 0.Not run locally: the rest of the ~162-family farm, which CI runs exactly once on this PR regardless.
Generated by Claude Code
Generated by Claude Code