diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b2108ff969..2138e5c85e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6154,6 +6154,33 @@ export class ObjectStackProtocolImplementation implements * `owner_id.name` — plausible from the select/sort axes — would be * silently dropped there, and this gate letting it through would * reintroduce the fallback it exists to close. + * + * ## The PROJECTION axis answers the same name differently — on purpose + * + * A name this gate refuses can still be spelled in `select` and come back + * 200 with the key simply absent: {@link assertProjectionFieldsExist} gates + * on whether a field is KNOWN, not on whether it is RETURNABLE, and the + * engine's read path then drops what the caller may not see + * (`omitInternalFields` for `internal: true` columns, + * `stripSearchCompanionFromRead` for the hidden `__search` companion). + * Measured on `__search`: `searchFields=__search` is a 400 here, while + * `select=__search` is a 200 whose body lacks it. + * + * That is not a gap someone forgot to close — it was asked as its own + * question and ruled intended on 2026-08-12 (#7876, direction C). The two + * axes are different KINDS of surface. `searchFields` is AUTHORING input: + * it tells the server how to RUN the query, so a value the server will not + * honour changes WHICH ROWS come back — the fail-open this gate exists for. + * `select` is a READ PROJECTION: it names what the caller would like back, + * the row set is untouched either way, and a column the caller may not see + * is simply not in the body. + * + * ⛔ Do not close the asymmetry by teaching the projection gate to refuse + * unreturnable columns. That was the alternative on #7876 and it was + * declined: it converts requests that answer 200 today into failures, for + * symmetry, on spellings with no measured callers. A real caller burned by + * a silent drop reopens the question on THAT measurement; the asymmetry + * alone does not. */ private assertSearchFieldsAreSearchable(object: string, requested: unknown, param: string): void { // Shape first, BEFORE the field-map tiering below — same order as the diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index c08be93f7d..019da10d39 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4853,6 +4853,33 @@ export class ObjectQL implements IObjectQLEngine { * not one. `isSystem` is server-derived (never client input), the same * trust the read-only strips on the write path already place in it. * + * ## Why this door is SILENT where the `$searchFields` door returns a 400 + * + * Asked on #7876 and ruled there on 2026-08-12 (direction C): the divergence + * is intended, and it is not reopenable on symmetry alone. The two doors are + * two KINDS of surface. + * + * - `$searchFields` is AUTHORING input — it tells the server how to RUN the + * query. A value the server will not honour has to be said out loud, or + * the caller gets a WIDER answer than the one they narrowed to, in a + * response with nothing to distinguish it from a satisfied one. That is + * why `assertSearchFieldsAreSearchable` refuses the name with a 400 + * (#4254) — the refusal is protecting the ROW SET. + * - `select` is a READ PROJECTION — it names what the caller would like + * back. Dropping a column the caller may not see leaves the answer + * correct: the rows are still the rows that were asked for, one key + * lighter. {@link omitInternalFields} directly above answers + * `?select=id,key` exactly this way, for exactly this reason (#7728), so + * silence here is the platform's existing read-path rule, not an + * exception to it. + * + * ⛔ Do not add a refusal here to make the two doors agree. That was option B + * on #7876, weighed and declined: it turns a request that answers 200 + * today into a failure, for tidiness, on a spelling no non-system caller in + * this tree uses — the companion's only deliberate reader is the backfill + * carved out above. If a REAL caller is ever burned by the silent drop, that + * measurement reopens it; the asymmetry by itself does not. + * * ⚠️ `requestedFields` must be the CALLER's `fields`, captured before * `planFormulaProjection` — that pass rewrites the projection to every stored * column when a formula is in play, companion included.