What this is
Six in-repo surfaces offer a dotted fields path (fields: ['owner.name']) as the supported way to read one related column. No driver implements it. They were describing behaviour that did not exist before #7532, and since #7532 they are additionally contradicted by a 400 INVALID_FIELD at the ingress gate.
This is a restore-invariant fix — declared should equal enforced, not an open product question. The direction is already settled by #7532's ruling: dotted projections are refused, and dotted-path resolution is a feature that card explicitly did not authorise. What is left is aligning the prose (and the artifacts generated from it) to the enforced behaviour.
Filed separately rather than inside #7588 because it is cross-package spec/docs surface that regenerates artifacts, and #7534 is in flight in the same file family — that is precisely the collision the serialization constraint exists to prevent. Unassigned and without a domain:* label so routing stays the triage seat's call.
The surfaces
The normative one first — the rest largely follow from it:
| # |
surface |
location |
| 1 |
spec fields description — normative; generates the JSON Schema and the contract docs, so fixing this one propagates |
packages/spec/src/data/query.zod.ts:484 |
| 2 |
type pins (queryInput canonical shape; fieldNodeDotted) |
packages/spec/src/recursive-schema-input-assertions.ts:74,99 |
| 3 |
query.joins retirement prescription (#4286 / ADR-0049) |
packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts:10 |
| 4 |
nested-select object-form retirement prescription (#4196) |
packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts:8 |
| 5 |
shipped JSON Schema artifacts (generated from 1) |
packages/spec/json-schema/api/FindDataRequest.json:154, .../ExportRequest.json:162, packages/spec/json-schema/objectstack.json:9791 |
| 6 |
docs prose |
content/docs/protocol/objectql/query-syntax.mdx:140,972; content/docs/references/data/query.mdx:125,134; content/docs/references/api/contract.mdx:407 |
Surfaces 3 and 4 are the sharpest: they are the automated upgrade prescriptions emitted to authors migrating off query.joins and off the retired { field, fields, alias } form. Both tell the author to write fields: ['owner.name'] — i.e. the migration tooling routes users directly into a spelling that now answers 400.
The measured fact that makes this actionable
Every surface above describes behaviour no driver implements. Measured on a real SqlDriver (better-sqlite3, :memory:), calling driver.find directly — no engine, no protocol:
no projection -> account amount created_at id name status updated_at
fields ['name'] -> name <- a plain name narrows
fields ['name','account.name'] -> account amount created_at id name status updated_at
fields ['account.name'] -> account amount created_at id name status updated_at
The dotted rows are byte-identical to no projection at all. Knex renders "account"."name" against a table that was never joined, sqlite answers no such column, and the #3821 recovery ladder retries select('*'). So the documented "read one related column" spelling never returned one related column — it returned every column. (That widening is itself filed as #7589.)
The coherent split — what to fix and what to leave
The zod schema still parses a dotted string, and that is correct and should stay: FieldNodeSchema is z.string(), a shape check. The semantic refusal lives at the ingress gate (assertProjectionFieldsExist, packages/metadata-protocol), which is where the field map is available to judge against.
So the fix is the .describe() prose and the artifacts generated from it — not the schema type. Specifically:
- Do not narrow
FieldNodeSchema to reject dots. That would move a semantic verdict into a shape check, refuse registry-less/internal callers the ingress deliberately tolerates, and duplicate a gate that already exists.
- Do rewrite the
fields description so it stops offering the dotted path, and regenerate the JSON Schema + contract docs from it.
- The type pins (surface 2) pin that
FieldNode admits a dotted string, which remains true at the type level. Decide whether to keep them as-is with a comment, or rename them so they stop reading as an endorsement of the feature.
What the corrected prose should say instead
expand is the sanctioned mechanism for related data on this door, and after #7532 it is the only one. It resolves reference fields into the related record via batched $in reads, driver-agnostic, and its nested QueryAST can both filter (where) and select (fields) the related record's columns.
Carry this sharp edge into the corrected prose — it is measured and it will bite:
The projection must retain the foreign-key column.
fields: ['title'], expand: 'project_id' -> nothing to resolve; no related record
fields: ['title', 'project_id'], expand: 'project_id' -> works
The relation is carried by the foreign key, so a narrowed projection that projects the FK away leaves expansion nothing to resolve. This is pinned as a GUARD test in #7588 ([#7532 GUARD] expand still delivers the related record the refusal prescribes), but it is not documented anywhere, and the prose being corrected here is exactly where a caller would look for it.
The other honest replacement, where the value is needed on the queried object itself, is to denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924), in the same words.
Not in scope
Implementing dotted-path resolution. #7532 explicitly did not authorise it, and this card is about making the declaration match the enforcement, not the other way round.
References
Generated by Claude Code
What this is
Six in-repo surfaces offer a dotted
fieldspath (fields: ['owner.name']) as the supported way to read one related column. No driver implements it. They were describing behaviour that did not exist before #7532, and since #7532 they are additionally contradicted by a400 INVALID_FIELDat the ingress gate.This is a restore-invariant fix — declared should equal enforced, not an open product question. The direction is already settled by #7532's ruling: dotted projections are refused, and dotted-path resolution is a feature that card explicitly did not authorise. What is left is aligning the prose (and the artifacts generated from it) to the enforced behaviour.
Filed separately rather than inside #7588 because it is cross-package spec/docs surface that regenerates artifacts, and #7534 is in flight in the same file family — that is precisely the collision the serialization constraint exists to prevent. Unassigned and without a
domain:*label so routing stays the triage seat's call.The surfaces
The normative one first — the rest largely follow from it:
fieldsdescription — normative; generates the JSON Schema and the contract docs, so fixing this one propagatespackages/spec/src/data/query.zod.ts:484queryInputcanonical shape;fieldNodeDotted)packages/spec/src/recursive-schema-input-assertions.ts:74,99query.joinsretirement prescription (#4286 / ADR-0049)packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts:10packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts:8packages/spec/json-schema/api/FindDataRequest.json:154,.../ExportRequest.json:162,packages/spec/json-schema/objectstack.json:9791content/docs/protocol/objectql/query-syntax.mdx:140,972;content/docs/references/data/query.mdx:125,134;content/docs/references/api/contract.mdx:407Surfaces 3 and 4 are the sharpest: they are the automated upgrade prescriptions emitted to authors migrating off
query.joinsand off the retired{ field, fields, alias }form. Both tell the author to writefields: ['owner.name']— i.e. the migration tooling routes users directly into a spelling that now answers400.The measured fact that makes this actionable
Every surface above describes behaviour no driver implements. Measured on a real
SqlDriver(better-sqlite3,:memory:), callingdriver.finddirectly — no engine, no protocol:The dotted rows are byte-identical to no projection at all. Knex renders
"account"."name"against a table that was never joined, sqlite answersno such column, and the #3821 recovery ladder retriesselect('*'). So the documented "read one related column" spelling never returned one related column — it returned every column. (That widening is itself filed as #7589.)The coherent split — what to fix and what to leave
The zod schema still parses a dotted string, and that is correct and should stay:
FieldNodeSchemaisz.string(), a shape check. The semantic refusal lives at the ingress gate (assertProjectionFieldsExist,packages/metadata-protocol), which is where the field map is available to judge against.So the fix is the
.describe()prose and the artifacts generated from it — not the schema type. Specifically:FieldNodeSchemato reject dots. That would move a semantic verdict into a shape check, refuse registry-less/internal callers the ingress deliberately tolerates, and duplicate a gate that already exists.fieldsdescription so it stops offering the dotted path, and regenerate the JSON Schema + contract docs from it.FieldNodeadmits a dotted string, which remains true at the type level. Decide whether to keep them as-is with a comment, or rename them so they stop reading as an endorsement of the feature.What the corrected prose should say instead
expandis the sanctioned mechanism for related data on this door, and after #7532 it is the only one. It resolves reference fields into the related record via batched$inreads, driver-agnostic, and its nestedQueryASTcan both filter (where) and select (fields) the related record's columns.Carry this sharp edge into the corrected prose — it is measured and it will bite:
The relation is carried by the foreign key, so a narrowed projection that projects the FK away leaves expansion nothing to resolve. This is pinned as a GUARD test in #7588 (
[#7532 GUARD] expand still delivers the related record the refusal prescribes), but it is not documented anywhere, and the prose being corrected here is exactly where a caller would look for it.The other honest replacement, where the value is needed on the queried object itself, is to denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924), in the same words.
Not in scope
Implementing dotted-path resolution. #7532 explicitly did not authorise it, and this card is about making the declaration match the enforcement, not the other way round.
References
fields:["name","account.name"]returns every field #7532 — the card that established the refusalSqlDriver's recovery ladder widening an unresolvable projection to every field (the mechanism behind the measurement above)QueryASTdeclares 12 members no executor runs — the liveness ledger governs metadata types, not the request surface #4286 / ADR-0049 (query.joinsremoval) and [P3] data:FieldNode's nested-select object form is declared but nothing produces or consumes it — enforce or remove #4196 (nested-select removal) — the two retirements whose prescriptions point at the dotted pathGenerated by Claude Code