From 859e11e62e8aa621527e7e09fb63f422ac5017eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:43:17 +0000 Subject: [PATCH] docs(objectql,metadata-protocol): pin why the two `__search` doors answer differently (#7876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$searchFields=__search` is a 400; `select=__search` is a 200 without the key. The maintainer ruled on 2026-08-12 that the divergence is intended (direction C) and asked for the reasoning to be written at both doors so the question is not reopened. Two comments, zero behaviour change: - `ObjectQL.stripSearchCompanionFromRead` — why this door is silent, with the option-B refusal explicitly declined and the condition that would reopen it. - `assertSearchFieldsAreSearchable` — the actual `$searchFields` refusal, which sits in the same file as the projection gate it diverges from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4 --- packages/metadata-protocol/src/protocol.ts | 27 ++++++++++++++++++++++ packages/objectql/src/engine.ts | 27 ++++++++++++++++++++++ 2 files changed, 54 insertions(+) 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.