Skip to content

Commit 02b1392

Browse files
fix(spec)!: the filter save and validate doors refuse a dotted addDays offset (#26655)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBTLty4kcd1RfdV2tNc1HXpk)_ Fixes #26601 Clause-②: no (narrowing) — the save and validate doors refuse a dotted `addDays` `$field` that every SQL read already refused. Nothing is widened. Metadata-protocol: narrowed (a filter, a dataset filter or a dataset measure filter may no longer carry an `addDays` offset whose `$field` is a dotted path) Skills-docs: none — `skills/objectstack-query/rules/filters.md` already teaches the offset as a number field of the same record and says SQL push-down refuses a dotted offset; this moves that refusal to save time and adds no new authoring rule. Moving "dotted" into that page's refused-at-400 list is a one-word follow-up for a skills PR (governed surface, kept out of this one). Ruling-ref: 6415710193 (letter A on #25808), seat ruling 6439436943, claim 6439488124. Scope is the author-time half only; B′ (an offset through a to-one path) is not in scope. ## 维护者速读 - **改了什么:** 过滤条件里 `addDays` 偏移量若写成带点的跨表路径(如 `contract_type.review_sla_days`),现在在保存与 `os validate` 时就被拒(`INVALID_FILTER` / 400),措辞与 SQL 读取时的拒绝一致;同时把数据集度量过滤器读取时被隐去的原因一并带出。 - **为什么改:** 以前 `os validate` 通过、看板每次读取都 400;内存求值器沿路径读到的是关联记录的 id,偏移按 0 天算,静默算错。裁决 A 已把偏移限定为同一记录的数值列。 - **风险与回滚:** 收窄(BREAKING):带点偏移的已存过滤器在保存时会被拒,但这些过滤器在任何 SQL 部署上本来就读不出来。回滚 = revert 本 PR。 - **先看:** `packages/spec/src/data/filter-comparand-type.ts`(`judgeAddDaysOffset`、`dottedAddDaysOffsetRefusal`)、`packages/spec/src/data/filter.zod.ts`(`AddDaysReferenceSchema` 的 `regex` 与 `addDaysOffsetMessage`)、`.changeset/26601-dotted-add-days-offset-refused.md`。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/data/filter.test.ts -t 26601`,预期 7 个用例全绿,其中数据集度量过滤器与 `ObjectStackDefinitionSchema` 均在 `measures.1.filter.review_started_at.$lt` 处被拒。 - **需要你做的:** 无,席位落地。 ## What changes - **The shared comparand walk** (`filter-comparand-type.ts`, `normalizeFilterComparandTypes`) still steps around a `{ $field }` reference and a well-formed `$now` anchor, except for one part: an `addDays` offset whose `$field` is a dotted path is refused at every scalar comparison with `INVALID_FILTER` / 400. The reason is `driver-sql`'s own sentence for the same refusal (`sql-driver-cross-field-comparison.ts:352` and `:517`): `the addDays offset "contract_type.review_sla_days" is a dotted path, and SQL push-down compiles same-table column references only (no relation traversal, no alias-qualified columns).` The walk runs at the `FilterConditionSchema` save door (data filters, dataset `filter`, dataset measure `filter`), in `parseFilterAST`, and at the engine's `where`, `aggregations[i].filter` and `having` doors. - **The offset schema** (`filter.zod.ts`, `AddDaysReferenceSchema`) refuses a dotted `$field` with the same reason. That covers `FieldReferenceSchema.addDays`, `NowAnchorSchema.addDays` and the ordering slots of `FieldOperatorsSchema` / `ComparisonOperatorSchema`. It is a `regex`, not a `refine`, so the published JSON Schema carries the rule as a `pattern` and the dropped-refinement ledger does not grow. - **The withheld 400 on a dataset measure filter.** Measured: the measure filter reaches the engine's `aggregations[i].filter` door, where `assertAggregationFilterIsEvaluable` (`having-filter.ts`) refuses cross-field rules in `where`'s withheld words, with no author-provenance restore. The walk runs before it (`engine.ts` near line 20463), so a dotted offset there is now refused by the walk with the path and reason named. No executor file changed. The other cross-field refusals at that door stay withheld (acceptance note below). - **Review 6438749334's two should-fixes** (the now-anchor extra-key refusal): the "there is no subDays" clause prints only when `subDays` is among the undeclared keys, so a one-key refusal of any other key keeps its last sentence inside the 500-character client bound; the stale `isNowAnchor` docblock sentence ("refused … by each executor at query time") is trimmed to name the walk. - **Superseded readings**, named per the seat: the #14104 ruling-record correction 5550346132 ("answered by the memory evaluator, refused by SQL push-down") and the spec pin at `filter.test.ts:1972` (the dotted nested reference accepted "exactly as `$field` itself allows"). Reason: ruling A on #25808 bounds the offset to a same-record column, and the only executor that walked the path, the formula evaluator, reads a lookup's id on a stored row and counts the offset as zero days. The pin now asserts the refusal. - **Docs**: `content/docs/protocol/objectql/query-syntax.mdx` loses "A dotted offset … validates" in both places it said so; `content/docs/references/data/filter.mdx` regenerated for the new describe. - **Not changed**: the reference's own `$field` (a dotted base reference keeps its asymmetry: memory walks it, SQL refuses it), the bare implicit form `{ field: { $field, addDays } }` (an operator spec named `$field`, whose fate is unchanged), and every executor. ## Executors measured before the change (origin/main c8ed4c9) - `driver-sql` (and `driver-sqlite-wasm`, which inherits it): refuses a dotted offset on the pair and the anchor (conformance cells `:858`, `:1118`). - `driver-mongodb`: refuses every `$field` and `$now` anchor by shape, dotted or not (`mongodb-filter.ts`, `carriesFieldReference` / `carriesNowAnchor`). - `@objectstack/formula` `matchesFilterCondition`: walks the path with `getPath`. On a flat row (the lookup holds an id) the offset reads undefined, counts as 0 days, and answers silently: an SLA of -10 days read as 0 counted a 4-day-old review as overdue. It answers correctly only on a record with the relation expanded. - `driver-memory` `find`: answers `[]` for any cross-field or anchor comparand handed to it directly (the known #15104 gap). - Every authoring door accepted it: `FieldReferenceSchema`, `NowAnchorSchema`, `FieldOperatorsSchema`, `FilterConditionSchema`, `parseFilterAST`, the walk, `DatasetSchema` measure `filter` and `filter`. ## Tests - `packages/spec/src/data/filter.test.ts`: a `[#26601]` block. The schemas refuse at `addDays.$field`; the four ordering slots on both operator copies; `FilterConditionSchema` at all six scalar comparisons and under `$and` / `$or` / `$not`; `DatasetSchema` measure `filter` and `filter` at the slot; `ObjectStackDefinitionSchema` (the schema `os validate` parses) at `datasets.0.measures.1.filter.review_started_at.$lt`, with a same-record control through the same door; controls for a same-record offset and a dotted base reference. The `:1972` pin flipped; the ordering-slot pin at `:1986` now uses a same-record offset. - `packages/spec/src/data/filter-comparand-type.test.ts`: the walk refuses on the pair, the anchor and the resolved anchor at all six comparisons, with code, status and the `driver-sql` sentence; under combinators and both `parseFilterAST` forms; the measure-filter position stays under 500 characters with its last sentence intact; controls kept by reference. The subDays clause is pinned both ways, plus a one-key refusal under `$and` with the engine prefix inside 500 characters. - `service-analytics` (tests only): `now-anchor-dataset.test.ts`, `cross-field-offset-dataset.test.ts` and `include-relation-cross-field-boundary.test.ts` asserted that the dotted offset validates at the schema door. They now assert the refusal, and they keep their read-time pins by putting the dotted filter back past the save door. The bare dotted arm still validates and is still refused by the driver. ## Acceptance notes - **Other withheld refusals at `aggregations[i].filter`** (out of scope, no card): an undeclared, non-numeric or tenant offset column in a dataset measure filter is still answered in `where`'s withheld words by `assertAggregationFilterIsEvaluable`, with no author-provenance restore. A dataset measure filter is the author's own, so a provenance mark there would restore those reasons; that is a design question for the analytics lane, not a pass-through in a file this PR touches. - **Disclosure at the engine doors**: the walk's refusal names the field path and the offset, as the walk's other refusals at the same doors already do (malformed anchor, comparand type). A row-level read scope cannot carry an `addDays` (the CEL compiler emits none), so no policy text reaches this sentence. - **Base `$field` asymmetry kept**: `{ $lte: { $field: 'duty.due_on' } }` still validates; memory walks it and SQL refuses it. Same reasoning would apply there, but it is outside this card's order. - **Formula evaluator called directly** still walks a dotted offset (`matches-filter-now-anchor.test.ts:69`). Every in-repo caller sits behind a door that runs the walk. ## Verification Package suites ran on `b9c396e87`; the merge of `origin/main` that made `c7f0010ef` brought no file in spec, service-analytics, objectql, formula, plugin-security, lint or driver-memory. Dogfood and the derived gates ran on `c7f0010ef`. Every command ran under `scripts/pm/os-verify-lock.sh`, with the exit code captured before any pipe. <details><summary>Package suites and typecheck</summary> - `@objectstack/spec` test: Test Files 730 passed, Tests 22073 passed, 1 todo. Typecheck exit 0. - `@objectstack/service-analytics` test: 191 files, 4647 passed, 282 skipped. Typecheck exit 0. - `@objectstack/objectql` test: 472 files, 8795 passed. Typecheck exit 0. - `@objectstack/formula` test: 54 files, 1452 passed. - `@objectstack/plugin-security` test: 229 files, 4962 passed, 45 skipped. - `@objectstack/lint` test: 166 files, 7389 passed. - `@objectstack/driver-memory` test: 72 files, 1758 passed. - `driver-sql` / `driver-sqlite-wasm` / `driver-mongodb` not re-run: none of them imports the walk or the offset schema, and no file of theirs changed. - `pnpm --filter @objectstack/spec check:generated --fix`, then plain: "All 14 generated artifacts are up to date" (only `references/data/filter.mdx` moved). </details> <details><summary>Dogfood (owed for the narrowing arm) at c7f0010</summary> `pnpm --filter @objectstack/dogfood test` after the `@objectstack/dogfood^...` closure build: VERDICT command-exit 0, Test Files 301 passed and 3 skipped (304), Tests 2501 passed and 18 skipped (2519). </details> <details><summary>Derived gates at c7f0010</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 106 commands, run serially with exit codes recorded. 105 exit 0. `check:skill-examples` first exited 3 (client SDK not built); after building `@objectstack/client-react...` it exited 0 with "266 prose examples type-check". `check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET, it needs the whole workspace built) and is left to CI. `--ran` reconciliation: "106 derived famil(ies) accounted for — 105 run, 1 NOT-MEASURED". </details> <details><summary>Reverse verification, both legs restored by blob hash</summary> - Walk leg: `dottedAddDaysOffset` made to answer `undefined` (`ablation-replace.mjs`, anchor 1 to 0, blob `48b975f2` to `edc0bfb4`). 6 failed / 231 passed across the two spec files. Every red is a walk or save-door pin: the scalar comparisons, the combinators, the length bound, the FilterCondition door, the dataset doors and the validate door. The schema-level pins stayed green. Restored: blob equals HEAD, `git diff HEAD` empty. - Schema leg: the `AddDaysReferenceSchema` pattern made vacuous (blob `2403f93a` to `34ee3e03`). 3 failed / 234 passed: the flipped `:1972` pin, the two schemas, and the ordering slots. The walk pins stayed green. Restored the same way. </details> <details><summary>Engine probe at the measure-filter door (objectql dist, after the change)</summary> `engine.aggregate('clm_contract', { aggregations: [{ function: 'count', filter: { review_started_at: { $lt: { $now: true, addDays: { $field: 'contract_type.review_sla_days' } } } } }] })` answers `INVALID_FILTER` / 400: `aggregate('clm_contract'): Filter comparand at aggregations[0].filter.review_started_at.$lt is refused: the addDays offset "contract_type.review_sla_days" is a dotted path, and SQL push-down compiles same-table column references only …`. The same measure with `addDays: { $field: 'review_sla_days' }` answers `[{"over":1}]`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzeyqyEtWpnqFTkcC9r)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe53769 commit 02b1392

10 files changed

Lines changed: 414 additions & 39 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: the filter save and validate doors refuse an `addDays` offset whose `{ $field }` is a dotted path, in the words SQL push-down already refuses it with on every read
6+
7+
An `addDays` offset reads its days from a column of the same record: `{ $field: 'due_on', addDays: { $field: 'grace_days' } }`, or `{ $now: true, addDays: { $field: 'review_sla_days' } }`. A dotted offset such as `addDays: { $field: 'contract_type.review_sla_days' }` passed `FieldReferenceSchema`, `NowAnchorSchema`, `FilterConditionSchema`, the dataset `filter` and measure `filter`, and `os validate`, and then failed every SQL read with `INVALID_FILTER` / 400. The one executor that walks the path, the memory evaluator, reads a lookup's id there on a stored row and counts the offset as zero days. Now every authoring door refuses it, naming the path: `the addDays offset "contract_type.review_sla_days" is a dotted path, and SQL push-down compiles same-table column references only (no relation traversal, no alias-qualified columns).`
8+
9+
This retires the earlier reading that the dotted offset validates and is answered in memory (the #14104 ruling-record correction 5550346132, and the spec pin that accepted `addDays: { $field: 'duty.grace_days' }`). Ruling A on #25808 bounds the offset to a same-record column.
10+
11+
Clause-②: no (narrowing) — the save and validate doors refuse a dotted `addDays` `$field` that every SQL read already refused. Nothing is widened.
12+
13+
Metadata-protocol: narrowed (a filter, a dataset filter or a dataset measure filter may no longer carry an `addDays` offset whose `$field` is a dotted path)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed. The dotted offset never compiled on SQL push-down and, on a stored row, read as zero days in memory, so there is no mechanical rewrite that keeps what the author meant: the related value has to be copied onto a numeric column of the row, which is an application change the author makes. -->
16+
17+
**BREAKING** accept-set narrowing on the `addDays` offset (`@objectstack/spec`).
18+
19+
- **Before.** `FieldReferenceSchema`, `NowAnchorSchema`, the ordering slots of `FieldOperatorsSchema`, `FilterConditionSchema` (data filters, dataset `filter`, dataset measure `filter`) and `os validate` accepted `addDays: { $field: 'a.b' }`. SQL reads answered `INVALID_FILTER` / 400; a dataset measure filter answered it with the reason withheld.
20+
- **Now.** Each of those doors refuses it. `parseFilterAST` and the engine's filter doors (`where`, `aggregations[i].filter`, `having`) refuse it with `INVALID_FILTER` / 400 and the path named, so a dataset measure filter that never met the save door now gets the reason too.
21+
- **Unchanged.** A same-record offset column, a literal offset, and the reference's own `$field` (a dotted base reference keeps its asymmetry: resolved in memory, refused by SQL push-down).
22+
- **Who sees it.** An author who read the offset through a relation. Every SQL deployment already refused that filter on read.
23+
24+
**The remedy.** Copy the related value onto a numeric column of the record (on create and whenever the relation changes) and name that column: `addDays: { $field: 'review_sla_days' }`.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,11 @@ On SQL push-down the offset compiles only between two temporal columns of the sa
509509
class (`date` with `date`, `datetime` with `datetime`) against a numeric offset column,
510510
on every dialect the `$field` compiler covers (SQLite, PostgreSQL, MySQL); the
511511
memory evaluator matches, and a fractional offset *value* is truncated toward zero on
512-
both. The same-table rule applies to the offset column too: `addDays: { $field:
513-
'duty.grace_days' }` (a relation path) is resolved by the memory evaluator and refused
514-
by SQL push-down with `INVALID_FILTER`, exactly as a dotted `$field` is.
512+
both. The same-table rule applies to the offset column too, and the offset is held to it
513+
at save time: `addDays: { $field: 'duty.grace_days' }` (a relation path) is refused with
514+
`INVALID_FILTER` by `os validate`, by the filter and dataset save doors and by every read,
515+
in the SQL push-down's own words. Only the referenced `$field` itself keeps the older
516+
asymmetry — resolved by the memory evaluator, refused by SQL push-down.
515517

516518
**A dataset's `include` does not widen this.** A dataset compiles a `LEFT JOIN` for every
517519
relation it `include`s (ADR-0071, up to three hops), and on the native-SQL path those joins
@@ -521,7 +523,8 @@ declines it so that the driver, which alone holds the declared field set, the co
521523
and the tenant column the cross-field rules check, compiles it (#7598) — and the driver
522524
compiles single-table statements, with no join in scope. So
523525
`completed_on <= due_on + duty.grace_days` is refused with `INVALID_FILTER` even when `duty`
524-
is in the dataset's `include`; measured and pinned in
526+
is in the dataset's `include` — at save time now, and by the driver for a dataset that
527+
never met the save door; measured and pinned in
525528
`packages/services/service-analytics/src/__tests__/include-relation-cross-field-boundary.test.ts`
526529
(#15103). The spelling that answers on both paths is the same-table one — every column the
527530
reference names, offset included, is a column of the object the filter runs over.
@@ -583,9 +586,11 @@ strategy declines a query carrying an anchor so that the driver compiles it, as
583586

584587
**The SLA held on a related record.** The shape that compiles is the same-table one: the SLA
585588
(or its negative) propagated onto the row, or the dataset's base object carrying the column.
586-
A dotted offset (`addDays: { $field: 'queue.sla_days' }`) validates, is resolved by the
587-
memory evaluator, and is refused by SQL push-down with `INVALID_FILTER`, exactly as a dotted
588-
`$field` is — a dataset's `include` does not widen it (see above).
589+
A dotted offset (`addDays: { $field: 'queue.sla_days' }`) is refused with `INVALID_FILTER`
590+
when the filter, dataset or stack is saved or validated, in the SQL push-down's words — a
591+
dataset's `include` does not widen it (see above). The memory evaluator would walk the path,
592+
but on a stored row the relation holds the related record's id, so the offset would read as
593+
zero days: copy the related value onto a numeric column of the row instead.
589594

590595
### Date, Datetime, and Time Filters
591596

‎content/docs/references/data/filter.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ A `{ $field }` reference to the numeric column holding the day offset
240240

241241
| Property | Type | Required | Description |
242242
| :--- | :--- | :--- | :--- |
243-
| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add |
243+
| **$field** | `string` | ✅ | Numeric column of the same record whose value is the number of days to add (no dotted path) |
244244

245245

246246
---
@@ -389,7 +389,7 @@ A `{ $field }` reference to the numeric column holding the day offset
389389

390390
| Property | Type | Required | Description |
391391
| :--- | :--- | :--- | :--- |
392-
| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add |
392+
| **$field** | `string` | ✅ | Numeric column of the same record whose value is the number of days to add (no dotted path) |
393393

394394

395395
---

‎packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,15 +339,20 @@ describe('[#14104] the dataset, valued end to end on the engine path', () => {
339339
expect(Number(byTitle.get('f')?.late ?? 0)).toBe(0);
340340
});
341341

342-
it('the dotted offset spelling of the ruling validates, routes, and is refused by SQL push-down by name', async () => {
343-
// Validates at the schema door (the memory evaluator walks the path).
342+
it('[#26601] the dotted offset spelling of the ruling is refused at the schema door, and past it by SQL push-down', async () => {
343+
// Refused at the schema door: the offset column is a column of the same
344+
// record (ruling A on #25808), and the memory evaluator, which walks the
345+
// path, reads a lookup's id there on a stored row — zero days, silently.
344346
const dotted = { completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'duty.grace_days' } } } };
345-
expect(DatasetSchema.safeParse({
347+
const saved = DatasetSchema.safeParse({
346348
...TASK_HEALTH,
347349
measures: [{ name: 'done_on_time', aggregate: 'count', filter: dotted }],
348-
}).success).toBe(true);
349-
// …and on a SQL deployment it is a loud INVALID_FILTER, never a wrong count:
350-
// the 2026-08-06 same-table ruling on #5222, inherited by the offset.
350+
});
351+
expect(saved.success).toBe(false);
352+
expect(saved.error?.issues[0]?.message).toContain('"duty.grace_days" is a dotted path');
353+
// …and a dataset that never met that door is a loud INVALID_FILTER on a SQL
354+
// deployment, never a wrong count: the 2026-08-06 same-table ruling on
355+
// #5222, inherited by the offset.
351356
let err: WireBearingError | null = null;
352357
try {
353358
await service.queryDataset(

‎packages/services/service-analytics/src/__tests__/include-relation-cross-field-boundary.test.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,16 @@ const RELATION_BARE: FilterCondition = {
106106
completed_on: { $lte: { $field: 'duty.grace_days' } },
107107
};
108108

109-
/** The dataset: `duty` IS in `include`, and a dimension already reads through it. */
110-
const DATASET = DatasetSchema.parse({
109+
/**
110+
* The dataset: `duty` IS in `include`, and a dimension already reads through it.
111+
*
112+
* [#26601] The offset arm is a DOTTED `addDays` offset, which the dataset write
113+
* door now refuses (ruling A on #25808 bounds the offset to a same-record
114+
* column). So the dataset is parsed with the same-table filter in that slot
115+
* and the dotted one put back PAST the save door — the read-time half of the
116+
* boundary is still this file's to pin, for a row that never met that door.
117+
*/
118+
const PARSED = DatasetSchema.parse({
111119
name: 'task_health_by_duty',
112120
label: 'Task health by duty',
113121
object: TASK,
@@ -119,10 +127,14 @@ const DATASET = DatasetSchema.parse({
119127
measures: [
120128
{ name: 'total', aggregate: 'count' },
121129
{ name: 'done_on_time', aggregate: 'count', filter: SAME_TABLE_ON_TIME },
122-
{ name: 'done_on_time_by_duty', aggregate: 'count', filter: RELATION_OFFSET_ON_TIME },
130+
{ name: 'done_on_time_by_duty', aggregate: 'count', filter: SAME_TABLE_ON_TIME },
123131
{ name: 'done_by_duty_bare', aggregate: 'count', filter: RELATION_BARE },
124132
],
125133
});
134+
const DATASET = {
135+
...PARSED,
136+
measures: PARSED.measures.map((m) => (m.name === 'done_on_time_by_duty' ? { ...m, filter: RELATION_OFFSET_ON_TIME } : m)),
137+
} as typeof PARSED;
126138

127139
interface WireBearingError extends Error {
128140
code?: string;
@@ -212,6 +224,17 @@ describe('[#15103] a cross-field comparand on an `include`d relation — the bou
212224
});
213225
});
214226

227+
describe('[#26601] the save door refuses the dotted OFFSET; the dotted bare reference still validates', () => {
228+
it('DatasetSchema refuses the offset arm at its slot, in the SQL read\'s words, and keeps the bare arm', () => {
229+
const offset = DatasetSchema.safeParse({ ...PARSED, measures: [{ name: 'by_duty', aggregate: 'count', filter: RELATION_OFFSET_ON_TIME }] });
230+
expect(offset.success).toBe(false);
231+
expect(offset.error?.issues[0]?.path).toEqual(['measures', 0, 'filter', 'completed_on', '$lte']);
232+
expect(offset.error?.issues[0]?.message).toContain('"duty.grace_days" is a dotted path');
233+
expect(offset.error?.issues[0]?.message).toContain('same-table column references only');
234+
expect(DatasetSchema.safeParse({ ...PARSED, measures: [{ name: 'by_duty', aggregate: 'count', filter: RELATION_BARE }] }).success).toBe(true);
235+
});
236+
});
237+
215238
describe('the `$field` COMPARAND never reaches that chain — refused even with `duty` in `include`', () => {
216239
for (const [arm, measure, ref] of [
217240
['offset', 'done_on_time_by_duty', 'duty.grace_days'],

‎packages/services/service-analytics/src/__tests__/now-anchor-dataset.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,14 @@ describe('[#25808] the dataset, valued end to end on the engine path', () => {
332332
expect(Number(byTitle.get('e')?.past_grace ?? 0)).toBe(0);
333333
});
334334

335-
it('the dotted offset spelling validates at the schema door and is refused by SQL push-down by name', async () => {
335+
it('[#26601] the dotted offset spelling is refused at the schema door, and past it by the read, by name', async () => {
336336
const dotted = { completed_on: { $lte: { $now: true, addDays: { $field: 'duty.sla_days' } } } };
337-
expect(DatasetSchema.safeParse({
337+
const saved = DatasetSchema.safeParse({
338338
...TASK_HEALTH,
339339
measures: [{ name: 'within_grace', aggregate: 'count', filter: dotted }],
340-
}).success).toBe(true);
340+
});
341+
expect(saved.success).toBe(false);
342+
expect(saved.error?.issues[0]?.message).toContain('"duty.sla_days" is a dotted path');
341343
const err = await (async () => {
342344
try {
343345
await service.queryDataset(

‎packages/spec/src/data/filter-comparand-type.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,95 @@ describe('[#26413] the now-anchor is closed at the door, as NowAnchorSchema decl
350350
}
351351
});
352352
});
353+
354+
describe('[#26413 review] the subDays clause answers subDays alone, so a one-key refusal stays inside the bound', () => {
355+
const NOT_APPLIED_TAIL = 'an unapplied filter would have returned the UNFILTERED result set.';
356+
357+
it('names the subDays rule when subDays is among the keys, and only then', () => {
358+
const subDays = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { $lt: { $now: true, addDays: -30, subDays: 7 } } }));
359+
expect(subDays?.message).toContain('there is no subDays: a negative addDays subtracts');
360+
const note = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { $lt: { $now: true, addDays: -30, note: 'x' } } }));
361+
expect(note?.code).toBe('INVALID_FILTER');
362+
expect(note?.message).toContain('undeclared key "note" — the anchor takes $now and addDays only.');
363+
expect(note?.message).not.toContain('subDays');
364+
});
365+
366+
it('a one-key refusal under the engine prefix, under $and, keeps its last sentence under 500 characters', () => {
367+
// The contract review's measured case was 496 characters with the clause
368+
// printed for `note`; a longer field and a path under `$and` pushed it over.
369+
const err = refusalOf(() => normalizeFilterComparandTypes(
370+
{ $and: [{ status: 'open' }, { review_opened_at: { $lte: { $now: true, addDays: -30, note: 'x' } } }] },
371+
"find('support_ticket')",
372+
));
373+
expect(err?.code).toBe('INVALID_FILTER');
374+
expect(err!.message.length).toBeLessThanOrEqual(500);
375+
expect(err!.message.endsWith(NOT_APPLIED_TAIL)).toBe(true);
376+
});
377+
});
378+
379+
describe('[#26601] a dotted addDays offset is refused at the door, on a reference and on an anchor', () => {
380+
const REF = 'contract_type.review_sla_days';
381+
/** `driver-sql`'s reason for the same refusal on every SQL read, verbatim. */
382+
const SQL_REASON = `the addDays offset "${REF}" is a dotted path, and SQL push-down compiles same-table `
383+
+ 'column references only (no relation traversal, no alias-qualified columns).';
384+
const COMPARANDS: Array<[string, Record<string, unknown>]> = [
385+
['the { $field, addDays } pair', { $field: 'review_due_at', addDays: { $field: REF } }],
386+
['the now-anchor', { $now: true, addDays: { $field: REF } }],
387+
['the now-anchor in its resolved form', { $now: '2026-03-03T12:00:00.000Z', addDays: { $field: REF } }],
388+
];
389+
390+
it('refuses it at every scalar comparison, in the SQL read\'s own words, with the envelope', () => {
391+
for (const [label, comparand] of COMPARANDS) {
392+
for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) {
393+
const err = refusalOf(() => normalizeFilterComparandTypes({ review_started_at: { [op]: comparand } }));
394+
expect(err?.code, `${label} ${op}`).toBe(StandardErrorCode.enum.INVALID_FILTER);
395+
expect(err?.status, `${label} ${op}`).toBe(400);
396+
expect(err?.message, `${label} ${op}`).toContain(`at where.review_started_at.${op} is refused: ${SQL_REASON}`);
397+
}
398+
}
399+
});
400+
401+
it('refuses it under $and / $or / $not and through parseFilterAST\'s two forms', () => {
402+
for (const [label, comparand] of COMPARANDS) {
403+
for (const filter of [
404+
{ $and: [{ status: 'open' }, { review_started_at: { $lt: comparand } }] },
405+
{ $or: [{ review_started_at: { $gte: comparand } }] },
406+
{ $not: { review_started_at: { $lt: comparand } } },
407+
]) {
408+
expect(refusalOf(() => normalizeFilterComparandTypes(filter))?.code, `${label} ${JSON.stringify(filter)}`).toBe('INVALID_FILTER');
409+
}
410+
expect(refusalOf(() => parseFilterAST({ review_started_at: { $lt: comparand } }))?.code, label).toBe('INVALID_FILTER');
411+
expect(refusalOf(() => parseFilterAST(['review_started_at', '<', comparand]))?.code, label).toBe('INVALID_FILTER');
412+
}
413+
});
414+
415+
it('the refusal at the measure-filter position keeps its last sentence under 500 characters', () => {
416+
const err = refusalOf(() => normalizeFilterComparandTypes(
417+
{ $and: [{ status: 'open' }, { contract_review_started_at: { $lte: { $now: true, addDays: { $field: 'contract_type_of_the_agreement.review_sla_days_offset' } } } }] },
418+
"aggregate('clm_contract_review')",
419+
'aggregations[0].filter',
420+
));
421+
expect(err?.code).toBe('INVALID_FILTER');
422+
expect(err!.message.length).toBeLessThanOrEqual(500);
423+
expect(err!.message.endsWith('an unapplied filter would have returned the UNFILTERED result set.')).toBe(true);
424+
});
425+
426+
it('CONTROL: a same-record offset column, a literal offset and a dotted BASE reference are kept by reference', () => {
427+
for (const comparand of [
428+
{ $field: 'review_due_at', addDays: { $field: 'review_sla_days' } },
429+
{ $now: true, addDays: { $field: 'review_sla_days' } },
430+
{ $now: true, addDays: -3 },
431+
// The reference's own `$field` is not this rule's: memory walks it, SQL refuses it.
432+
{ $field: 'contract_type.review_due_at' },
433+
{ $field: 'contract_type.review_due_at', addDays: 2 },
434+
]) {
435+
const filter = { review_started_at: { $lt: comparand } };
436+
expect(normalizeFilterComparandTypes(filter), JSON.stringify(comparand)).toBe(filter);
437+
}
438+
});
439+
440+
it('CONTROL: the bare implicit form keeps its own fate — an operator spec named $field, not judged here', () => {
441+
const filter = { review_started_at: { $field: 'review_due_at', addDays: { $field: REF } } };
442+
expect(normalizeFilterComparandTypes(filter)).toBe(filter);
443+
});
444+
});

0 commit comments

Comments
 (0)