Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/26413-now-anchor-extra-key-refused.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: NowAnchorSchema declared exactly $now and addDays since it shipped, and this aligns the walk with that declaration. An extra key never had a meaning at any executor, so there is no rewrite that keeps what the author meant; the author decides, and the refusal names the key. -->

**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: <integer | { $field }> }`, 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' }`.
8 changes: 7 additions & 1 deletion packages/formula/src/matches-filter-now-anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
58 changes: 58 additions & 0 deletions packages/objectql/src/engine-now-anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
55 changes: 55 additions & 0 deletions packages/spec/src/data/filter-comparand-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
normalizeFilterComparandTypes,
parseFilterAST,
FieldOperatorsSchema,
NowAnchorSchema,
} from './index';
import { StandardErrorCode } from '../api/errors.zod';

Expand Down Expand Up @@ -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, unknown>, 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);
}
});
});
41 changes: 32 additions & 9 deletions packages/spec/src/data/filter-comparand-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(['$now', 'addDays']);

function undeclaredNowAnchorKeys(value: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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}. `
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/src/data/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } });
Expand Down
Loading