Skip to content

Commit 1b2eb1b

Browse files
os-zhuangclaude
andauthored
fix(objectql)!: engine.find/findOne refuse a dotted projection instead of widening to every field (#7589) (#8327)
* fix(objectql)!: engine.find/findOne refuse a dotted projection instead of widening to every field (#7589) The head-only known.has(head) filter kept dotted entries on the strength of a comment claiming the engine resolves them via populate; #7601 measured no populate step exists. A dotted entry is now 400 INVALID_FIELD at the engine boundary; the unknown-plain-column tolerance is explicitly kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy * test(objectql): pin the #7589 engine-door dotted-projection refusal, the kept plain tolerance, and the cross-door wording agreement Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy * chore(spec): register the engine-dotted-projection-refused ADR-0087 semantic entry + changeset (#7589) One entry file under entries/semantic/, registry region + spec-changes.json + upgrade guide regenerated. Breaking changeset per the #7095 precedent: same door (engine public API), same class (silent degradation becomes a refusal). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy * docs(objectql): teach expand, not a dotted projection, for reading a lookup's related column (#7589) types.mdx's lookup query example was the populate premise itself — the spelling both doors now refuse. Replaced with the expand form, reference column kept projected (#7537), refusal + remedy stated inline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb3896c commit 1b2eb1b

8 files changed

Lines changed: 557 additions & 24 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
<!-- adr-0087: registered engine-dotted-projection-refused -->
6+
7+
fix(objectql)!: `engine.find` / `engine.findOne` refuse a dotted projection instead of widening the response to every field (#7589)
8+
9+
`engine.find()` and `engine.findOne()` are a **public API**, and a `fields`
10+
entry carrying a dotted path (`['name', 'account.name']`) — which used to
11+
answer 200 with **every** column, byte-identical to no projection at all —
12+
now **throws `400 INVALID_FIELD`**.
13+
14+
#7532 (PR #7588) closed this at the REST ingress
15+
(`assertProjectionFieldsExist`), covering everything that reaches `findData`.
16+
A caller reaching the engine directly passed through none of it, and that
17+
caller set was measured, not assumed (#7589): a flow `get_record` node's
18+
authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema`
19+
restricts nothing), travels verbatim into `data.find(...)` /
20+
`data.findOne(...)`, cleared the engine's head-only projection filter on its
21+
head segment (`account` IS a field), and reached the driver as a projection
22+
column — where SQL renders `"account"."name"` against a table that was never
23+
joined, the DB answers `no such column`, and the driver's #3821 recovery
24+
ladder retries `select('*')`. The caller asked to narrow and silently
25+
received everything, pointing away from both FLS and data minimisation. A
26+
saved report's `query.fields` (`plugin-reports` forwards it verbatim) reached
27+
it the same way.
28+
29+
The head-only check was justified by its own comment: "the engine will
30+
resolve those via populate". **No populate step exists**#7601 measured it,
31+
and this comment was the last place in the repo asserting dotted-path
32+
resolution does. The comment and the check it explained are gone together;
33+
what is removed is not a working feature but a path to widening, kept alive
34+
by a false premise.
35+
36+
**FROM → TO**: a direct-engine caller (flow `get_record` `fields`, saved
37+
report `query.fields`, hook code) projecting `account.name` reads the related
38+
record with `expand` (`{ expand: { account: { object: '<target>', fields:
39+
['name'] } } }`) while keeping the reference column itself in `fields`, or
40+
denormalises the value onto the queried object (a stored field, written when
41+
the source changes) and names that. A plain reference column (`fields:
42+
['account']`) still projects.
43+
44+
**Deliberately KEPT** (same ruling, 2026-08-12): the unknown-PLAIN-column
45+
tolerance — an unknown plain name is still dropped silently and an
46+
all-unknown projection still falls back to `*`, because the "no records
47+
exist" failure that tolerance prevents is real. A registry-less host (no
48+
field map) gets **no** verdict, exactly as the ingress gate returns early
49+
there; for that host the driver-side #3821 ladder remains the documented
50+
backstop, and a driver-side carve-out stays measured-need only. One path is
51+
observable rather than refused: a dotted `fields` inside a nested `expand`
52+
raises this refusal inside `expandRelatedRecords`' pre-existing
53+
graceful-degradation `catch`, so it logs a warning naming the field and the
54+
fix and retains the raw foreign keys — the same posture the sort axis (#7095)
55+
records for the same catch.

content/docs/protocol/objectql/types.mdx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,11 +605,18 @@ account_id:
605605

606606
**Storage:** Stores `id` of referenced record
607607

608-
**Query behavior:**
608+
**Query behavior:** `expand` is the door for related data. A dotted `fields`
609+
entry (`'account_id.company_name'`) is **refused** (`400 INVALID_FIELD`) — no
610+
driver resolves one, at the REST ingress since #7532 and on direct
611+
`engine.find` / `engine.findOne` calls since #7589. Keep the reference column
612+
itself in the projection: the relation is carried by `account_id`, and
613+
projecting it away leaves the expansion nothing to resolve.
614+
609615
```typescript
610-
// Expand the account lookup
616+
// Read a column of the related account
611617
const opportunities = await engine.find('opportunity', {
612-
fields: ['name', 'account.company_name'] // Expands account
618+
fields: ['name', 'account_id'],
619+
expand: { account_id: { object: 'account', fields: ['company_name'] } },
613620
});
614621
```
615622

docs/protocol-upgrade-guide.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,13 @@ What makes this one cheaper to meet than its two siblings, and worth saying beca
394394
- **`driver-sql-distinct-bare-filter-typed`**`SqlDriver.distinct() third argument — any value` → a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope
395395
- Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320.
396396
- Done when: No caller passes a non-object to `distinct()`'s third argument. A scalar there is now a compile error (`TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'`); rewrite it as the bare filter it was always meant to be — `'completed'` becomes `{ status: 'completed' }`. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400.
397+
- **`engine-dotted-projection-refused`**`engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress` → read the related record with `expand` (`{ expand: { account: { object: '<target>', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924
398+
- Why not automatic: #7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `"account"."name"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.
399+
400+
Ruled 2026-08-12 on #7589 (Option B): a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths "via populate"; #7601 measured that NO populate step exists — after PR #7617 that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side #3821 ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the sort axis (#7095) records for the same catch.
401+
402+
This is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration. #7589, #7532, #7601, #3821, #5918, ADR-0112.
403+
- Done when: No `engine.find` / `engine.findOne` call site passes a dotted `fields` entry, no flow `get_record` config authors one, and no saved report's `query.fields` names one — grep flow definitions and report definitions for a `fields` entry containing a `.`, and rewrite each to `expand` (keeping the reference column projected) or to a denormalised stored column. Reads complete with no `INVALID_FIELD` whose message says "follows the relationship" or "a dotted path", and no "Failed to expand relationship field" warning whose error text does.
397404
- **`engine-find-formula-order-by-refused`**`engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress` → denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column
398405
- Why not automatic: #4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.
399406

0 commit comments

Comments
 (0)