Skip to content

Commit f9f9f91

Browse files
fix(service-analytics): runtime strings state each decision in words instead of a tracker number (stage 7) (#21561)
Part of #20751 Clause-②: no **Stage 7 of the `domain:services` lane under the maintainer's A / A ruling (5902360492): `service-analytics`, part 1 of 2 (`analytics-service.ts`, `comparand-shape.ts`, `strategies/filter-normalizer.ts`).** The card stays open for stage 8, so this PR carries no closing keyword. Text only: no status, error `code`, field, route, export or control flow moves (the AST skeleton reads SAME for 4 of 4 changed `.ts` files, below). ## What this does Seventeen strings in these three files sent the reader to a tracker number for the reason behind them. In form D, as stages 1 to 6 applied it, the number goes. Where the sentence already said what was decided, only the citation goes. Where it leaned on the number, it now says the decision in words. All 21 ledgered occurrences in this stage's surface (claim `5966570749`), re-derived from the ledger on `origin/main` at `81e69cab` (where the branch was cut): `analytics-service.ts` 4 (4 pairs), `comparand-shape.ts` 7 (3 pairs), `strategies/filter-normalizer.ts` 10 (7 pairs), in 17 string sites. That matches the seat's reading (4 ids; 3 ids, 7 occurrences; 7 ids). The four files excluded at dispatch (`read-scope-sql.ts`, `strategies/native-sql-strategy.ts`, `strategies/objectql-strategy.ts`, `preview-evaluator.ts`, held by 21505) are not touched; their 13 occurrences stay for stage 8. ### Rewritten in words Caller- and author-visible text first, the log line last. Line numbers are at the head `fd6f53c7`. | Where | Cited | The text now says | Decision read from | |---|---|---|---| | `comparand-shape.ts:596-611`, `fieldReferenceComparandMessage` (a `{ $field }` comparand the SQL lowering cannot render) | 5222, 7598 x2 | "driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators, and the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path ... the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns, so those rules are enforced in one place, next to the metadata they read"; the closing "(/analytics/query) to get its rows" drops its citation | 5222's maintainer rulings (2026-08-06, restated 2026-08-11): same-table columns only, dotted paths refused; declared-only enumeration; the tenant-isolation column forbidden on both sides; plus the comparison-class rule the implementation added. 7598's ruling (2026-08-12, Q1 = B): native SQL declines a `$field` query so it routes to the engine path, where the driver enforces all four rulings with metadata it owns, so the security rules exist in one place, with no second copy and no new `StrategyContext` hook; the `/analytics/sql` echo declines too ("one consistent loud answer, no half-rendering", which the sentence already said) | | `comparand-shape.ts:650-664`, `fieldReferenceBetweenBoundMessage` (a `{ $field }` used as a `$between` bound) | 5222 x2, 7596, 7598 | "@objectstack/spec no longer declares the position at all (FieldReferenceSchema was removed from the $between endpoint union rather than implemented there, since nothing asked for it, ADR-0049 declared = enforced)"; "on the ObjectQL engine path, where the driver enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class)"; "driver-sql and driver-sqlite-wasm refuse both endpoints" drops its citation | 7596's ruling (2026-08-11): remove `FieldReferenceSchema` from both `$between` endpoints and rule out `$in` / `$nin` members, declared = enforced by removal, with no member-resolution implementation without measured demand. 5222 and 7598 as above | | `analytics-service.ts:3865-3874`, the no-strategy diagnostic for a cross-field filter on a deployment with no aggregate bridge (a thrown `Error`, no code) | 5222, 7598 | "the ObjectQL engine path, whose driver compiles it and enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place, next to the metadata they read" | as the first row | | `filter-normalizer.ts:863-877`, the undefined-comparand refusal (`INVALID_FILTER` / 400) | 3650, 6050, 6386 | "(a dropped predicate WIDENS the query, which this module refuses everywhere else)"; "An undefined comparand is refused rather than read as null, on the SQL drivers and on this door alike." | 3650: a silently dropped `dateRange` drew a full-history chart with no error; the lesson the family carries is that a dropped predicate widens the query. 6050's ruling (2026-08-07, option B): an `undefined` comparand is refused loudly (`INVALID_FILTER` / 400), never read as null, because the spec declares no such comparand and an undefined key cannot be told from an absent one. 6386 took the same refusal to this `where` door (its PR removed the drop-the-key line and added `assertDefinedComparands`) | | `filter-normalizer.ts:1013-1026`, the mixed `$`-operator / bare-key wrapper refusal | 3650, 6444 | "the failure mode this module refuses everywhere else"; "already fails closed on this exact shape, so both doors refuse it: one shape, one answer." | 6444's ruling (2026-08-08, option A): refuse the mixed wrapper in this module's envelope rather than flatten it, converging with `read-scope-sql`, which already failed closed on the same input. The widening clause already said "a dropped conjunct does not narrow the query, it WIDENS it", so 3650 only drops | | `filter-normalizer.ts:1115-1118`, the zero-operator field constraint refusal | 5240 | "neither reading is the author's intent (a filter that recorded a field and never its operator), so this shape is refused on every backend." | the maintainer's ruling on 5240 (2026-08-04): `{ field: {} }` is refused (`INVALID_FILTER`) on every backend, neither TRUE nor FALSE, so a half-built filter fails at authoring instead of quietly returning more or fewer rows | | `filter-normalizer.ts:1449-1457`, the filter-array refusal | 5158, 5334 | "lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included, so it means the same rows whichever door it enters." | 5158's ruling (2026-08-04, option C): `FilterArray` is input-only authoring sugar, lowered through `parseFilterAST` at the doors, so drivers keep no array dialect. 5334's ruling: the analytics `where` door lowers the same way (an empty array is no filter, any other array it cannot lower is refused), so one dashboard filter answers the same on `find()` and on a chart | | `filter-normalizer.ts:2067-2070`, the `isFilterAST` / `parseFilterAST` disagreement refusal | 5158, 5334 | "Refusing rather than charting the dataset unfiltered: a filter array is lowered at every door or refused, never dropped." | as the row above | | `analytics-service.ts:3969-3976`, the dotted-measure refusal (`INVALID_FIELD` / 400) | 5918 | "Before this refusal the prefix was silently dropped" (citation only: the sentence already says measures do not traverse relationships, so there is no related column to aggregate) | 5918's ruling (2026-08-07, option 3): refuse a dotted measure loudly, naming the caller's spelling, because a measure has no traversal answer to converge on | | `analytics-service.ts:3667-3670`, the no-object-registry warning (`warn`, once) | 3867 | "the cube-inference existence gate, which answers 404 CUBE_NOT_FOUND for a name that is neither a registered cube nor a registered object, is INACTIVE for this service" | 3867's landed change (PR 3875): an inferred cube must name a registered object, and a name that is neither a registered cube nor a registered object answers 404 `CUBE_NOT_FOUND` before any SQL forms; with no registry probe configured the gate stands down and warns once | Every cited card (12: 3650, 3867, 5158, 5222, 5240, 5334, 5918, 6050, 6386, 6444, 7596, 7598) was read through REST, body and every comment, before its string was rewritten. All twelve answer 200. ### Published contract check None of these strings is a spec-declared message or an i18n key. They are refusal, diagnostic and log text built inside `service-analytics`. A repository-wide search for each old fragment outside the three files finds no assertion and no doc quoting it; the other hits are code comments, test comments and two similar sentences of their own in the stage-8 strategy files. `fieldReferenceComparandMessage` and `fieldReferenceBetweenBoundMessage` are also emitted through `read-scope-sql.ts`, a stage-8 file this PR does not touch; its own wrapper text is unchanged. ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Regenerated with `node scripts/check-doc-authoring.mjs --census-ledger` (exit 0, no growth refusal). The diff deletes 20 lines and adds none: exactly the three file blocks of this stage. A scripted key-by-key comparison of the branch-point copy against the regenerated one reads 14 (file, id) pairs moved, all of them this stage's, each to absent; every other row is unchanged. No other open PR touches the file (open PRs read at 07:1xZ and again at PR-open time). | | before (`81e69cab`) | after | |---|---|---| | `analytics-service.ts` | 4 occurrences, 4 pairs | 0 | | `comparand-shape.ts` | 7 occurrences, 3 pairs | 0 | | `strategies/filter-normalizer.ts` | 10 occurrences, 7 pairs | 0 | | whole ledger | 34 occurrences, 27 pairs, 6 files | 13, 13, 3 (the stage-8 files) | `pnpm check:doc-authoring` at the head: "sibling-package prose ids hold the baseline — 9 pinned site(s) across 3 file(s), 86207 string(s) read in 1252 parsed source(s), no growth, no burn-down unrecorded". No gate is added or loosened; `scripts/check-doc-authoring.mjs` is untouched. ## Changeset `.changeset/20751-services-strings-stage7-state-the-decision.md`: `patch` for `@objectstack/service-analytics`. Measured after the full build: every new sentence is in `dist/index.js` and `dist/index.cjs`, and none of the old citation fragments from these three files is (the one `#6050 ruling B` hit left in `dist` is `read-scope-sql.ts`'s own refusal, a stage-8 string). A TypeScript scan of every string literal and template text in the built output finds 13 tracker ids in each file, and they are exactly the ids the remaining stage-8 ledger entries carry (read-scope 5, native-SQL 2, ObjectQL 6), which is also the scan's positive control. ## Text-only proof A TypeScript-AST skeleton of each changed `.ts` file, where every string literal and template text is a placeholder, a run of adjacent string operands of a `+` chain is one string (only its embedded expressions are kept), identifiers and numbers keep their text, and comments are never read. `81e69cab` against the head: 4 of 4 SAME. Controls on scratch copies of `filter-normalizer.ts`, each mutation's marker counted once on disk first: a one-identifier rename reads DIFF; a text-only change reads SAME; a re-split of one string into two concatenated pieces reads SAME. ## Pins - `cross-field-engine-fallback.test.ts:419`: the no-aggregate-bridge diagnostic is found by "so those rules are enforced in one place" instead of the id. The two assertions beside it ("budget", "executeAggregate") and the narrowness control are unchanged. This string is a plain `Error` with no code or status, so no envelope assertion exists to keep. Reverse check at the committed head, under the lock, through `scripts/ablation-replace.mjs`: the new clause put back to the citation form (anchor hit once, blob moved) turned exactly that case red (predicted 1, measured 1 of 93). Restored byte-identical to `HEAD` with an empty `git diff HEAD`. - No other test asserts any of the seventeen strings by an id or by a fragment this PR rewrites. The refusal tests keep their `code` / `status` and fragment assertions ("zero operators", "mixes $-operator keys", "WIDENS", "read-scope-sql.ts", "No strategy can handle"), all still present. ## Tests All through `scripts/pm/os-verify-lock.sh`, every verdict `VERDICT command-exit 0`, at the head `fd6f53c7`: - Build: `turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` (71/71). - `@objectstack/service-analytics`, `vitest run --maxWorkers=2`: 174 files, 4142 tests passed, 247 skipped. - `@objectstack/service-analytics` `typecheck` (`tsc --noEmit` over `src`): exit 0. `--listFiles` counts 174 test files in that program, the re-pinned one among them. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) at `fd6f53c7` (6 paths vs merge base `81e69cab`, 108 changed lines): 72 commands, run one at a time from the worktree after the full build, each exit code recorded before any pipe; 72 exit 0. `--ran`: "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero — all 72 recorded an exit code and none of them is 3)". - `check-issue-citations`: "no issue citations added against 81e69ca (3 file(s) read)"; `check:nul-bytes`: OK, 9910 files; `check:type-check-debt`: "none above its recorded number"; `check:dual-build-cjs-loads`: 106 require entry points across 66 packages load; `check:dts-closure`: 71 built packages, 169/169; `check:sourcemap-no-sources-content`: 68 packages, 532 maps; `check:published-files`: 69 publishable packages; `check:engine-double-contract`: OK; `check-adr-0087-registration`: no declared-breaking changeset. - Outside the derived set, all exit 0 at `fd6f53c7`: the eleven declared wide-population families (`check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:route-envelope`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in`, `check:wildcard-fallthrough`), plus `check:durability-log-level` and `check:error-code-casing` (log and refusal text moved; no level or code did). - Lint, narrowed as a measurement: `eslint --no-inline-config --format json` over the 4 changed `.ts` files at `fd6f53c7`: 4 files linted, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. Repo-wide `pnpm lint` is CI's. - `origin/main` moved to `10454b3a` (four commits: PRs 21555, 21539, 21473, 21554) after the branch point. None touches `service-analytics` or the ledger, and none adds or removes an id-bearing line in a non-test package source, so the recomputed ledger stands on that tree; the branch is not merged. 21505 has no PR yet, so the dispatch's merge condition did not arise. ## Acceptance notes Noted, not filed: - Code comments beside the rewritten strings still carry ids (for example the `[#7598]` and `[#3867]` docblocks); comments are outside the ledger and belong to the sibling comment card. Carrier: none. - Test titles and comments still carry ids (the `[#7598]` describe title in `cross-field-engine-fallback.test.ts`; `measure-source-field-gate.test.ts:140` quotes the old "Until #5918" wording in a comment). Test bodies and comments are outside the ledger. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5dbcee8 commit f9f9f91

6 files changed

Lines changed: 58 additions & 50 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
Analytics filter refusals, the no-strategy diagnostic and the cube-gate warning no longer cite tracker numbers; each one states the decision behind it in words
6+
7+
Clause-②: no
8+
9+
Some strings the analytics service shows to callers, authors and operators pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
10+
11+
- The two field-reference refusals (a `{ $field }` comparand the SQL lowering cannot render, and a `{ $field }` used as a `$between` bound) say the engine path's driver enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place. The bound refusal also says `FieldReferenceSchema` was removed from the `$between` endpoint union rather than implemented there, since nothing asked for it.
12+
- The no-strategy diagnostic for a cross-field filter on a deployment with no aggregate bridge says the same about the engine path.
13+
- The `where` refusals: an undefined comparand is refused rather than read as null, on the SQL drivers and on this door alike; a field constraint with zero operators is refused on every backend, because neither "every row" nor "no row" is the author's intent; a field constraint mixing `$` operators with bare keys is refused by both doors in the package; and the two filter-array refusals say a filter array is lowered at every door or refused, never dropped, so it means the same rows whichever door it enters. Where the undefined-comparand refusal cited a tracker number for the silent widening, it now says that a dropped predicate widens the query; the mixed-wrapper refusal already said so and only drops its citation.
14+
- The dotted-measure refusal drops its citation; the sentence already says measures do not traverse relationships and that the prefix used to be dropped silently.
15+
- The warning logged when no object-registry hook is configured says the inactive gate is the one that answers 404 `CUBE_NOT_FOUND` for a name that is neither a registered cube nor a registered object.
16+
17+
Text only: no status, error code, field, route or control flow moves. A client or log filter that matches the old text (for example a tracker-number suffix) needs the new spelling.

‎packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the
416416
const err = await errorFrom(() => run({ amount: { $gt: { $field: 'budget' } } }));
417417
expect(err.message).toContain('budget');
418418
expect(err.message).toContain('executeAggregate');
419-
expect(err.message).toContain('#7598');
419+
expect(err.message).toContain('so those rules are enforced in one place');
420420

421421
// …and the narrowness control, which is the half that makes the sentence
422422
// trustworthy: a literal filter on the SAME deployment still runs.

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3664,9 +3664,10 @@ export class AnalyticsService implements IAnalyticsService {
36643664
if (!this.warnedNoObjectRegistry) {
36653665
this.warnedNoObjectRegistry = true;
36663666
this.logger.warn(
3667-
'[Analytics] no object-registry hook configured — the cube-inference existence gate ' +
3668-
'(#3867) is INACTIVE for this service; an unregistered cube name reaches the driver ' +
3669-
'as a raw table name.',
3667+
'[Analytics] no object-registry hook configured — the cube-inference existence gate, ' +
3668+
'which answers 404 CUBE_NOT_FOUND for a name that is neither a registered cube nor a ' +
3669+
'registered object, is INACTIVE for this service; an unregistered cube name reaches the ' +
3670+
'driver as a raw table name.',
36703671
);
36713672
}
36723673
return;
@@ -3864,8 +3865,10 @@ export class AnalyticsService implements IAnalyticsService {
38643865
? `This query's filter compares against the field reference ` +
38653866
`{ "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}", and ` +
38663867
`NativeSQLStrategy DECLINES a cross-field comparison so that it routes to the ObjectQL ` +
3867-
`engine path — whose driver compiles it and enforces the #5222 rulings with metadata it ` +
3868-
`owns (#7598). No such path is configured here, so the capability is unavailable on this ` +
3868+
`engine path — whose driver compiles it and enforces the cross-field rules (declared ` +
3869+
`same-table columns only, never the tenant-isolation column, one comparison class) with ` +
3870+
`metadata it owns, so those rules are enforced in one place, next to the metadata they ` +
3871+
`read. No such path is configured here, so the capability is unavailable on this ` +
38693872
`deployment: supply an \`executeAggregate\` bridge (the plugin auto-wires one from the ` +
38703873
`engine), or compare against a literal value. Every other query on this cube is ` +
38713874
`unaffected. `
@@ -3965,7 +3968,7 @@ function mintableMeasureKey(member: string, cubeName: string): string {
39653968
throw invalidMemberError(
39663969
`[Analytics] Measure '${member}' on cube '${cubeName}' is a DOTTED member, and ` +
39673970
`measures do not traverse relationships — only dimensions do — so there is no ` +
3968-
`related column for this to aggregate. Until #5918 the prefix was silently ` +
3971+
`related column for this to aggregate. Before this refusal the prefix was silently ` +
39693972
`dropped, so the aggregate ran against '${cubeName}' itself while the result ` +
39703973
`column kept the label '${member}'. Aggregate one of the object's OWN fields ` +
39713974
`instead ('<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct'), or ` +

‎packages/services/service-analytics/src/comparand-shape.ts‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -601,13 +601,14 @@ export function fieldReferenceComparandMessage(
601601
`with nothing to read. ⚠️ This is NOT the platform declining the rule. @objectstack/spec ` +
602602
`declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in ` +
603603
`memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the ` +
604-
`six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics ` +
605-
`native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs ` +
606-
`there — the driver enforcing declared-only enumeration, the tenant-isolation ban and the ` +
607-
`comparison class with metadata it owns. What refuses here is this SQL lowering, whose only ` +
608-
`remaining caller is the /analytics/sql display echo; it has no faithful rendering of the ` +
609-
`predicate the engine path actually runs, and half-rendering one would describe a query that ` +
610-
`returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`
604+
`six scalar operators, and the analytics native-SQL strategy DECLINES such a query so it ` +
605+
`routes to the ObjectQL engine path and runs there — the driver enforcing declared-only ` +
606+
`enumeration, the tenant-isolation ban and the comparison class with metadata it owns, so ` +
607+
`those rules are enforced in one place, next to the metadata they read. What refuses here is ` +
608+
`this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no ` +
609+
`faithful rendering of the predicate the engine path actually runs, and half-rendering one ` +
610+
`would describe a query that returns different rows. Run the query itself (/analytics/query) ` +
611+
`to get its rows.`
611612
);
612613
}
613614

@@ -648,17 +649,19 @@ export function fieldReferenceBetweenBoundMessage(
648649
return (
649650
`"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its ` +
650651
`[min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and ` +
651-
`driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a ` +
652+
`driver-sqlite-wasm refuse both endpoints, @objectstack/formula does not resolve a ` +
652653
`reference inside a list either — it orders the bounds against the raw reference object, which ` +
653654
`no value compares meaningfully to — and @objectstack/spec no longer declares the position at ` +
654-
`all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = ` +
655-
`enforced). Refusing rather than lowering it: this compiler splits $between into its two ` +
655+
`all (FieldReferenceSchema was removed from the $between endpoint union rather than implemented ` +
656+
`there, since nothing asked for it, ADR-0049 declared = enforced). Refusing rather than ` +
657+
`lowering it: this compiler splits $between into its two ` +
656658
`bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never ` +
657659
`wrote — a position the SQL drivers DO compile — and the range would quietly succeed here ` +
658660
`while the identical filter is refused everywhere else. Use a literal bound, or spell the ` +
659661
`comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), ` +
660-
`which IS served — on the ObjectQL engine path, where the driver enforces the #5222 rulings ` +
661-
`(#7598).`
662+
`which IS served — on the ObjectQL engine path, where the driver enforces the cross-field ` +
663+
`rules (declared same-table columns only, never the tenant-isolation column, one comparison ` +
664+
`class).`
662665
);
663666
}
664667

‎packages/services/service-analytics/src/strategies/filter-normalizer.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -865,14 +865,16 @@ function undefinedComparandError(field: string, path: string): Error {
865865
`whose value is undefined cannot be told apart from an ABSENT key — yet the two mean OPPOSITE ` +
866866
`things (a predicate versus no constraint at all), so there is no reading of it that is not a ` +
867867
`guess. It used to compile, two ways: in a FIELD position the key was dropped outright, so a ` +
868-
`single-key where ran with no filter at all and the chart was drawn over every row (#3650's ` +
869-
`widening, which this module refuses everywhere else); in an OPERATOR or list position it ` +
870-
`became a comparison against null, which is UNKNOWN for every row and charts nothing. ` +
868+
`single-key where ran with no filter at all and the chart was drawn over every row (a dropped ` +
869+
`predicate WIDENS the query, which this module refuses everywhere else); in an OPERATOR or ` +
870+
`list position it became a comparison against null, which is UNKNOWN for every row and charts ` +
871+
`nothing. ` +
871872
`Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), ` +
872873
`or omit the key entirely when the value is genuinely absent — an omitted key is the same "no ` +
873874
`constraint" without the ambiguity. The producer to fix is whoever BUILT this where: undefined ` +
874875
`cannot cross JSON, so it is in-process code spreading a possibly-absent value into a filter ` +
875-
`object (#6050 ruling B, pushed down to this door by #6386).`,
876+
`object. An undefined comparand is refused rather than read as null, on the SQL drivers and on ` +
877+
`this door alike.`,
876878
);
877879
}
878880

@@ -1019,9 +1021,9 @@ function mixedFieldWrapperError(field: string, opKeys: string[], nonOpKeys: stri
10191021
`explicitly: { "$and": [{ "${field}": { "$op": ... } }, { "${field}": { "${example}": ... } }] }. ` +
10201022
`This shape used to compile by silently DROPPING every non-$ sibling, and a dropped conjunct ` +
10211023
`does not narrow the query, it WIDENS it: the chart included rows the author excluded, with ` +
1022-
`nothing to read (#3650's failure mode, which this module refuses everywhere else). The sibling ` +
1023-
`door in this package (read-scope-sql.ts) already fails closed on this exact shape — one shape, ` +
1024-
`one answer (#6444).`,
1024+
`nothing to read — the failure mode this module refuses everywhere else. The sibling ` +
1025+
`door in this package (read-scope-sql.ts) already fails closed on this exact shape, so both ` +
1026+
`doors refuse it: one shape, one answer.`,
10251027
);
10261028
}
10271029

@@ -1111,8 +1113,9 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] {
11111113
if (Object.keys(wrapper).length === 0) {
11121114
throw invalidFilterError(
11131115
`[analytics] "${key}" carries a field constraint with zero operators ({}). ` +
1114-
`Refusing rather than reading it as "every row" or "no row" — #5240 ruled this ` +
1115-
`shape refused on every backend.`,
1116+
`Refusing rather than reading it as "every row" or "no row": neither reading is the ` +
1117+
`author's intent (a filter that recorded a field and never its operator), so this shape ` +
1118+
`is refused on every backend.`,
11161119
);
11171120
}
11181121
// [#6444] A wrapper mixing $-operator keys with non-$ siblings is refused
@@ -1447,7 +1450,8 @@ function filterArrayNotLowerableError(where: unknown[]): Error {
14471450
`A filter array is a comparison [field, operator, value], a logical node ` +
14481451
`["and"|"or", ...conditions], or a list of those — it is INPUT-ONLY sugar (spec ` +
14491452
`'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at ` +
1450-
`every door, this one included (#5158/#5334). This value cannot be lowered, and an ` +
1453+
`every door, this one included, so it means the same rows whichever door it enters. This ` +
1454+
`value cannot be lowered, and an ` +
14511455
`unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ` +
14521456
`${[...VALID_AST_OPERATORS].sort().join(', ')}. Infix joins ([condA, "or", condB]) are ` +
14531457
`NOT one of the shapes — write the prefix form ["or", condA, condB].`,
@@ -2062,7 +2066,8 @@ export function lowerAnalyticsWhere(
20622066
throw invalidFilterError(
20632067
`[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but ` +
20642068
`parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than ` +
2065-
`charting the dataset unfiltered (#5158/#5334).`,
2069+
`charting the dataset unfiltered: a filter array is lowered at every door or refused, ` +
2070+
`never dropped.`,
20662071
);
20672072
}
20682073
return condition as Record<string, unknown>;

‎scripts/doc-authoring-prose-id.baseline.json‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
11
{
2-
"packages/services/service-analytics/src/analytics-service.ts": {
3-
"#3867": 1,
4-
"#5222": 1,
5-
"#5918": 1,
6-
"#7598": 1
7-
},
8-
"packages/services/service-analytics/src/comparand-shape.ts": {
9-
"#5222": 3,
10-
"#7596": 1,
11-
"#7598": 3
12-
},
132
"packages/services/service-analytics/src/read-scope-sql.ts": {
143
"#5347": 1,
154
"#5369": 1,
165
"#6050": 1,
176
"#6125": 1,
187
"#6387": 1
198
},
20-
"packages/services/service-analytics/src/strategies/filter-normalizer.ts": {
21-
"#3650": 2,
22-
"#5158": 2,
23-
"#5240": 1,
24-
"#5334": 2,
25-
"#6050": 1,
26-
"#6386": 1,
27-
"#6444": 1
28-
},
299
"packages/services/service-analytics/src/strategies/native-sql-strategy.ts": {
3010
"#5222": 1,
3111
"#7598": 1

0 commit comments

Comments
 (0)