Skip to content

Commit fd6f53c

Browse files
committed
fix(service-analytics): runtime strings state each decision in words instead of a tracker number (stage 7)
The analytics-service.ts, comparand-shape.ts and filter-normalizer.ts strings that sent the reader to a tracker number now say what was decided: the engine path's driver enforces the cross-field rules in one place; a $between bound may not be a field reference because the schema removed that position; an undefined comparand, a zero-operator field constraint and a mixed $/bare wrapper are refused rather than guessed at; a filter array is lowered at every door or refused, never dropped. The prose-id ledger is recomputed with --census-ledger: the three files' entries go to zero and no other entry moves. Text only. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81e69ca commit fd6f53c

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)