From 38a5ac652251d7d2c7f9232b6737a79a32e0fa04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:44:38 +0000 Subject: [PATCH 1/2] fix(spec)!: every filter door refuses a $now anchor that carries a key beyond $now / addDays isWellFormedNowAnchor read addDays only, so { $now: true, addDays: -30, subDays: 7 } passed the comparand-type walk that every filter door runs (find object and array form, aggregate where, aggregations[i].filter, having, and FilterConditionSchema on save) and ran on the addDays reading alone, while NowAnchorSchema declares the anchor closed. The walk now refuses any other key with INVALID_FILTER / 400, and the refusal names the key and says the anchor takes $now and addDays only. Claude-Session: https://claude.ai/code/session_01WGufzf89Y8Q23nPngB94Bo Co-authored-by: Claude --- .../src/matches-filter-now-anchor.test.ts | 8 ++- .../objectql/src/engine-now-anchor.test.ts | 58 +++++++++++++++++++ .../src/data/filter-comparand-type.test.ts | 55 ++++++++++++++++++ .../spec/src/data/filter-comparand-type.ts | 41 ++++++++++--- packages/spec/src/data/filter.test.ts | 12 ++++ 5 files changed, 164 insertions(+), 10 deletions(-) diff --git a/packages/formula/src/matches-filter-now-anchor.test.ts b/packages/formula/src/matches-filter-now-anchor.test.ts index f09a8e9ff8d..9feae2dd229 100644 --- a/packages/formula/src/matches-filter-now-anchor.test.ts +++ b/packages/formula/src/matches-filter-now-anchor.test.ts @@ -164,7 +164,13 @@ describe('[#25808] the own-clock arm and the fail-closed arms', () => { expect(ids({ completed_on: { $lte: anchored({ $field: 'id' }) } })).toEqual(['2', '3', '4', '6', '8', '9']); }); - it('the anchor is read by SHAPE: an extra key does not disqualify it', () => { + // [#26413] An extra key is refused at every filter door before a filter + // reaches this evaluator (`normalizeFilterComparandTypes` in + // `@objectstack/spec`: `INVALID_FILTER` / 400, the key named). The engine + // calls this evaluator for `having` and the `aggregations[i].filter` rows + // fallback, both behind that door; the CEL lowering for the RLS `check` + // emits no anchor. So the evaluator does not judge the anchor's keys again. + it('behind the door, the evaluator reads the anchor by SHAPE: it does not re-judge an extra key', () => { expect(ids({ completed_on: { $eq: { ...anchored(0), note: 'x' } } })).toEqual(['2']); }); }); diff --git a/packages/objectql/src/engine-now-anchor.test.ts b/packages/objectql/src/engine-now-anchor.test.ts index 3f512db6b05..1cf1d437ebb 100644 --- a/packages/objectql/src/engine-now-anchor.test.ts +++ b/packages/objectql/src/engine-now-anchor.test.ts @@ -256,4 +256,62 @@ describe('[#25808] the now-anchored comparand on the engine', () => { expect(err.message).toContain('now-anchored'); }); }); + + describe('[#26413] an undeclared key on the anchor gets one answer at every door', () => { + const EXTRA = { $now: true, addDays: -30, subDays: 7 }; + const CLEAN = { $now: true, addDays: -30 }; + + const refusedWithKey = (err: Wire) => { + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('undeclared key "subDays"'); + expect(err.message).toContain('the anchor takes $now and addDays only'); + }; + + it('`find()` object-form `where`: refused before any driver runs; the clean anchor runs', async () => { + seen.length = 0; + refusedWithKey(await refusalOf(() => engine.find('ticket', { where: { opened_at: { $lt: EXTRA } } }))); + expect(seen).toHaveLength(0); + await engine.find('ticket', { where: { opened_at: { $lt: CLEAN } } }); + expect(seen).toHaveLength(1); + }); + + it('`find()` array-form `where`: refused the same way', async () => { + seen.length = 0; + refusedWithKey(await refusalOf(() => engine.find('ticket', { where: [['opened_at', '<', EXTRA]] } as never))); + expect(seen).toHaveLength(0); + await engine.find('ticket', { where: [['opened_at', '<', CLEAN]] } as never); + expect(seen).toHaveLength(1); + }); + + it('`aggregate()` `where`: refused the same way', async () => { + refusedWithKey(await refusalOf(() => engine.aggregate('ticket', { + where: { opened_at: { $lt: EXTRA } }, + aggregations: [{ function: 'count', alias: 'n' }], + }))); + await expect(engine.aggregate('ticket', { + where: { opened_at: { $lt: CLEAN } }, + aggregations: [{ function: 'count', alias: 'n' }], + })).resolves.toBeDefined(); + }); + + it('`aggregations[i].filter`: refused the same way', async () => { + refusedWithKey(await refusalOf(() => engine.aggregate('ticket', { + aggregations: [{ function: 'count', alias: 'x', filter: { opened_at: { $lt: EXTRA } } }], + }))); + await expect(engine.aggregate('ticket', { + aggregations: [{ function: 'count', alias: 'x', filter: { opened_at: { $lt: CLEAN } } }], + })).resolves.toBeDefined(); + }); + + it('`having`: refused the same way', async () => { + const grouped = (anchor: unknown) => engine.aggregate('ticket', { + groupBy: ['region'], + aggregations: [{ function: 'min', field: 'opened_at', alias: 'first_opened' }], + having: { first_opened: { $lt: anchor } } as never, + }); + refusedWithKey(await refusalOf(() => grouped(EXTRA))); + await expect(grouped(CLEAN)).resolves.toBeDefined(); + }); + }); }); diff --git a/packages/spec/src/data/filter-comparand-type.test.ts b/packages/spec/src/data/filter-comparand-type.test.ts index 36aae6935c8..8da5b5250bc 100644 --- a/packages/spec/src/data/filter-comparand-type.test.ts +++ b/packages/spec/src/data/filter-comparand-type.test.ts @@ -21,6 +21,7 @@ import { normalizeFilterComparandTypes, parseFilterAST, FieldOperatorsSchema, + NowAnchorSchema, } from './index'; import { StandardErrorCode } from '../api/errors.zod'; @@ -295,3 +296,57 @@ describe('parseFilterAST is the compile face that runs the door', () => { expect(parseFilterAST(null)).toBeUndefined(); }); }); + +describe('[#26413] the now-anchor is closed at the door, as NowAnchorSchema declares', () => { + const EXTRA_KEY_ANCHORS: Array<[string, Record, string]> = [ + ['a subDays beside an integer offset', { $now: true, addDays: -30, subDays: 7 }, 'subDays'], + ['a note beside a { $field } offset', { $now: true, addDays: { $field: 'sla_days' }, note: 'x' }, 'note'], + ['the resolved form the engine hands the executors', { $now: '2026-03-03T12:00:00.000Z', addDays: 0, note: 'x' }, 'note'], + ]; + + it('the door\'s key set is the schema\'s shape: $now and addDays, nothing else', () => { + expect(Object.keys(NowAnchorSchema.shape).sort()).toEqual(['$now', 'addDays']); + }); + + it('refuses an undeclared key at every scalar comparison, naming the key', () => { + for (const [label, anchor, key] of EXTRA_KEY_ANCHORS) { + for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) { + const err = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { [op]: anchor } })); + expect(err?.code, `${label} ${op}`).toBe('INVALID_FILTER'); + expect(err?.status, `${label} ${op}`).toBe(400); + expect(err?.message, `${label} ${op}`).toContain(`undeclared key "${key}"`); + expect(err?.message, `${label} ${op}`).toContain('the anchor takes $now and addDays only'); + } + } + }); + + it('refuses it under $and / $or / $not and through parseFilterAST\'s two forms', () => { + const anchor = { $now: true, addDays: 1, subDays: 1 }; + for (const filter of [ + { $and: [{ status: 'open' }, { opened_at: { $lt: anchor } }] }, + { $or: [{ opened_at: { $gte: anchor } }] }, + { $not: { opened_at: { $lt: anchor } } }, + ]) { + expect(refusalOf(() => normalizeFilterComparandTypes(filter))?.code, JSON.stringify(filter)).toBe('INVALID_FILTER'); + } + expect(refusalOf(() => parseFilterAST({ opened_at: { $lt: anchor } }))?.code).toBe('INVALID_FILTER'); + expect(refusalOf(() => parseFilterAST(['opened_at', '<', anchor]))?.code).toBe('INVALID_FILTER'); + }); + + it('names every undeclared key, and names it before a malformed offset', () => { + const err = refusalOf(() => normalizeFilterComparandTypes({ opened_at: { $lt: { $now: true, subDays: 7, note: 'x' } } })); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.message).toContain('undeclared key "subDays", "note"'); + }); + + it('keeps a well-formed anchor, on both offset arms and in its resolved form, by reference', () => { + for (const anchor of [ + { $now: true, addDays: -30 }, + { $now: true, addDays: { $field: 'sla_days' } }, + { $now: '2026-03-03T12:00:00.000Z', addDays: 0 }, + ]) { + const filter = { opened_at: { $lt: anchor } }; + expect(normalizeFilterComparandTypes(filter)).toBe(filter); + } + }); +}); diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index 67aaf23c18e..a7e880598f9 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -224,25 +224,48 @@ function isNowAnchor(value: unknown): boolean { return isFilterNode(value) && (value.$now === true || typeof value.$now === 'string'); } +/** + * [#26413] The keys a now-anchor carries: `NowAnchorSchema` is closed over + * exactly these two, and `filter-comparand-type.test.ts` reconciles this set + * against the schema's own shape. Restated rather than imported because + * `filter.zod.ts` imports this module. + */ +const NOW_ANCHOR_KEYS: ReadonlySet = new Set(['$now', 'addDays']); + +function undeclaredNowAnchorKeys(value: Record): string[] { + return Object.keys(value).filter((key) => !NOW_ANCHOR_KEYS.has(key)); +} + /** * [#25808] Is this anchor WELL-FORMED — `addDays` present, an integer or a - * `{ $field: string }`? The door steps around a well-formed anchor as it steps - * around a reference; a malformed one (`{ $now: true }` with no offset, a - * fractional or string offset) is refused HERE in the anchor's own words, - * because the `FilterCondition` door runs this walk and not the operator - * schemas, and an exemption by `$now` alone would have let the malformed shape - * through to the executors as a plain object they each refuse differently. + * `{ $field: string }`, and no other key? The door steps around a well-formed + * anchor as it steps around a reference; a malformed one (`{ $now: true }` + * with no offset, a fractional or string offset) is refused HERE in the + * anchor's own words, because the `FilterCondition` door runs this walk and + * not the operator schemas, and an exemption by `$now` alone would have let + * the malformed shape through to the executors as a plain object they each + * refuse differently. + * + * [#26413] An undeclared key (`subDays`, `note`) makes the anchor malformed + * too, as `NowAnchorSchema` declares: every executor reads `$now` and + * `addDays` only, so a kept extra key would run the query on the `addDays` + * reading alone and say nothing. */ function isWellFormedNowAnchor(value: Record): boolean { + if (undeclaredNowAnchorKeys(value).length > 0) return false; const offset = value.addDays; if (typeof offset === 'number') return Number.isInteger(offset); return isFilterNode(offset) && typeof offset.$field === 'string'; } function malformedNowAnchorRefusal(context: string | undefined, path: string, value: Record): Error { - const detail = value.addDays === undefined - ? 'it carries no addDays — for the unshifted instant write the "{now}" placeholder' - : `its addDays (${preview(value.addDays)}) is neither an integer nor a { "$field": "numeric_column" } reference`; + const undeclared = undeclaredNowAnchorKeys(value); + const detail = undeclared.length > 0 + ? `it carries an undeclared key ${undeclared.map((key) => preview(key)).join(', ')} — the anchor takes ` + + '$now and addDays only (there is no subDays: a negative addDays subtracts)' + : value.addDays === undefined + ? 'it carries no addDays — for the unshifted instant write the "{now}" placeholder' + : `its addDays (${preview(value.addDays)}) is neither an integer nor a { "$field": "numeric_column" } reference`; return invalidComparandError( context, `Filter comparand at ${path} is a malformed now-anchored comparand (${preview(value)}): ${detail}. ` diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index cf9c39e732c..9895c0c3cbb 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -265,6 +265,18 @@ describe('[#25808] NowAnchorSchema — the now-anchored comparand', () => { expect(NowAnchorSchema.safeParse({ $now: true, addDays: -1, note: 'x' }).success).toBe(false); }); + it('[#26413] …and the `FilterCondition` save door refuses the extra key too, naming it', () => { + for (const [anchor, key] of [ + [{ $now: true, addDays: -30, subDays: 7 }, 'subDays'], + [{ $now: true, addDays: GRACE, note: 'x' }, 'note'], + ] as const) { + const result = FilterConditionSchema.safeParse({ opened_at: { $lt: anchor } }); + expect(result.success, key).toBe(false); + expect(JSON.stringify(result.error?.issues), key).toContain(`undeclared key \\"${key}\\"`); + } + expect(FilterConditionSchema.safeParse({ opened_at: { $lt: { $now: true, addDays: GRACE } } }).success).toBe(true); + }); + it('is legal exactly where a { $field } reference is: the six scalar comparisons', () => { for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) { const result = FilterConditionSchema.safeParse({ opened_at: { [op]: { $now: true, addDays: GRACE } } }); From 98b536ffd50ac587f83c82e55b5aadaa5db73118 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:51:36 +0000 Subject: [PATCH 2/2] chore(changeset): the closed now-anchor at every filter door Claude-Session: https://claude.ai/code/session_01WGufzf89Y8Q23nPngB94Bo Co-authored-by: Claude --- .../26413-now-anchor-extra-key-refused.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/26413-now-anchor-extra-key-refused.md diff --git a/.changeset/26413-now-anchor-extra-key-refused.md b/.changeset/26413-now-anchor-extra-key-refused.md new file mode 100644 index 00000000000..b9f8777948f --- /dev/null +++ b/.changeset/26413-now-anchor-extra-key-refused.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: every filter door refuses a `{ $now, addDays }` anchor that carries any other key, as `NowAnchorSchema` already declares, instead of running it on the `addDays` reading alone + +`NowAnchorSchema` is closed: `{ $now: true, addDays: -30, subDays: 7 }` fails to parse on its own. But the comparand-type walk every filter door runs (`normalizeFilterComparandTypes`, through `FilterConditionSchema` and `parseFilterAST`) judged an anchor by its `addDays` alone, so the extra key was kept, every executor ignored it, and the query answered the `addDays` reading with no word. The walk now refuses an anchor with any key other than `$now` and `addDays`, with the existing `INVALID_FILTER` / 400 envelope, and the refusal names the key: `it carries an undeclared key "subDays" — the anchor takes $now and addDays only (there is no subDays: a negative addDays subtracts)`. + +Clause-②: no (narrowing) — every filter door refuses a `$now` anchor carrying a key beyond `$now` / `addDays`, which it ran before. Nothing is widened. + +Metadata-protocol: narrowed (a filter saved through `FilterConditionSchema` may no longer carry a key beside `$now` and `addDays` on a now-anchored comparand) + + + +**BREAKING** accept-set narrowing at the filter comparand-type walk (`@objectstack/spec`), served at every filter door. + +- **Before.** `engine.find()` / `count()` / `update()` in object or array form, `aggregate()`'s `where`, `aggregations[i].filter`, `having`, and a filter saved through `FilterConditionSchema` all kept `{ $now: true, addDays: N, subDays: M }` or `{ …, note: 'x' }`. The query ran on `addDays` alone. +- **Now.** Each of them answers `INVALID_FILTER` / 400 (a save fails its parse), naming the undeclared key. +- **Unchanged.** A well-formed anchor, `{ $now: true, addDays: }`, and its resolved form (`$now` holding the pinned ISO instant) run as before at every door. The refusals for a missing or malformed `addDays` keep their wording. +- **Who sees it.** An author who wrote an extra key on an anchor. That key was ignored before, so the rows answered were the `addDays` rows. + +**The remedy.** Drop the extra key. To move the instant back, give `addDays` a negative value; to read the offset off a column, use `addDays: { $field: 'numeric_column' }`.