Skip to content

[Decision] Two doors answer __search differently: the projection door drops it silently, $searchFields refuses it with 400 "is hidden" #7876

Closed

Description

@huangyiirene

Filed by the domain:engine-core seat (#6019, session session_01VGAePF7iGGUYUT8oX1cVgx) from PR #7868's open question 2. ⛔ Nothing shipped depends on this — #7868 landed (8b90d68) and is coherent either way. This is about whether two doors should agree.

The question

A non-system caller names the hidden __search companion column explicitly. Two doors, one column, two different answers:

door spelling answer today
$searchFields override __search 400 — "is hidden"
projection / select ?select=__search silent drop — 200, key absent (as of #7868)

Should the projection door also refuse with a 400, or is silence correct there?

Premises, each with its re-check command

  • $searchFields refuses the spelling with a 400 — git grep -n "is hidden" origin/main -- packages/objectql/src
  • The projection door drops it silently, added by fix(objectql): strip the hidden __search companion from every record body (#7642) #7868 — git grep -n "stripSearchCompanionFromRead" origin/main -- packages/objectql/src/engine.ts
  • assertProjectionFieldsExist gates only on whether a field is known, not on whether it is returnable — git grep -n "assertProjectionFieldsExist" origin/main -- packages/objectql/src
  • A system caller naming it still receives it (the plugin-pinyin-search backfill depends on this) — git grep -n "isSearchCompanionRequested" origin/main -- packages/objectql/src

Options

A — keep the silent drop (status quo, shipped).
The projection door's existing contract for a field it will not return is silence, not refusal. A 400 here is a new refusal on a spelling that used to answer 200.
Cost: the two doors keep disagreeing, and a client that asks for the column gets a 200 whose body silently lacks what was asked for.

B — make the projection door refuse too (400 "is hidden").
One column, one answer, whichever door you knock on. Matches how the platform already answers this exact spelling elsewhere.
Cost: a behaviour change on a request that currently succeeds; anything that today passes select=__search and tolerates the missing key would start failing. Needs the system-caller carve-out preserved.

C — rule that the divergence is intended and record why.
The two doors mean different things: $searchFields is authoring input (a wrong value is an authoring error), select is a read projection (a field you cannot see is simply not in the body).
Cost: none mechanically; the cost is that the next reader re-asks this question unless the reasoning is written where they will meet it.

Recommendation: C, with the reasoning pinned in the code.

The divergence looks accidental but is defensible: the two doors are different kinds of surface, and B changes a currently-succeeding request for tidiness rather than for a user-visible defect. But C is only worth anything if the reasoning is written at stripSearchCompanionFromRead and at the $searchFields refusal, so this does not get re-litigated.

⚠️ If you prefer B, it is a small change, but ⛔ it must not disturb the system-caller carve-out — plugin-pinyin-search's backfill projects ['id', …sources, '__search'] under isSystem and compares stored-vs-recomputed; breaking it makes the walk rewrite every row of every object on every pass.

Four-lens

  1. Platform long-term coherence — B shrinks special-casing (one column, one answer); C keeps two rules but gives them a stated reason. A leaves an unexplained inconsistency, which is the worst of the three for coherence.
  2. Measured business pull — zero today. No in-tree caller passes select=__search; the column is a private index and the only deliberate reader is the system backfill. Under the zero-pull default this is defer-or-record, not build — which is what makes C the proportionate answer and B the one that needs a reason beyond symmetry.
  3. AI-agent error-resistance — B is strongest: a loud refusal on a spelling that cannot work is much harder for an AI author to misuse than a 200 whose body quietly lacks the requested key. This is the one lens that genuinely favours B, and it is the reason this is a decision rather than a shrug.
  4. Startup scope discipline — C costs two comments; B costs a behaviour change plus a regression surface on a zero-pull path. Cheapest honest answer wins unless lens 3 outweighs it.

Related: #7642 (origin) · PR #7868 (8b90d68) · #2486 (the companion column).

Activity

  1. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Maintainer ruling — 2026-08-12

    裁定:方向 C —— 分歧是有意的,把理由钉进代码。

    要点:

    • 两个门是两类面:$searchFields 是作者输入(错值是作者错误,400 正确),select 是读投影(不可见的字段就是不在 body 里,静默正确)。
    • 在 stripSearchCompanionFromRead 和 $searchFields 的拒绝处各写一条注释,把上述理由与本裁定钉住,防止下一个读者重开这题。
    • ⛔ 零行为变化;⛔ 系统调用者 carve-out(plugin-pinyin-search backfill 依赖 isSystem 下投影 __search)不许碰。
    • 树内无人 select=__search(零业务拉力)—— 若未来有真实调用方被静默丢弃坑到,再以那次实测为据重议 B。

    裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话 session_01GZKbx4xyF7U5WXj6ch49BM 代笔落卡。转 pm:queue(两条注释,小卡)。


    Generated by Claude Code

  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 12, 2026
  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  4. self-assigned this
    on Aug 12, 2026
  5. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_014C8pAprWdmtecFsEprZax4
    Branch: claude/issue-7876-search-door-divergence-pin
    Worktree: objectstack-issue-7876
    Domain: domain:engine-core
    File surface: packages/objectql/src/engine.ts (stripSearchCompanionFromRead, :4794-4860) + packages/objectql/src/search-companion.ts (:224) — comments only (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus
    Serial constraints cleared: engine.ts — sites measured on origin/main at :4794-4860 / :7035 / :7126 / :7270; in-flight #5320 at :3813 (gap 981), in-flight #7933 at :9257 (gap 1,987), unlanded PR #7989 at :7922-8727 (gap 652 from the nearest site). No pair can become one hunk at three lines of context. search-companion.ts is free — #7642 and #7641 both landed.

    ⚠️ Correcting the inherited ledger in public, per this seat's commitment 11: this card was carried in the handover's decision box as an open maintainer question. It is not — the maintainer ruled it direction C at 03:05:16Z today, and the needs-user-decision → pm:queue swap that followed was correct. The ledger went stale against a ruling recorded by a different session under the same account. Same correction applies to #7865 and #7378; #7929 is not this lane's at all (domain:drivers). The lane's decision box is empty.

    Seat #6019, domain:engine-core. The 03:05Z ruling is carried into the dispatch verbatim and is not reopenable.


    Generated by Claude Code

  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor
    {
      "issue": 7876,
      "status": "done",
      "branch": "claude/issue-7876-search-door-divergence-pin",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8079",
      "premise_still_valid": true,
      "summary": "Direction C implemented as two comments, zero behaviour change (54 added lines, every one a continuation line inside an existing block comment, zero deletions). Comment 1 sits at `ObjectQL.stripSearchCompanionFromRead` as the ruling directs; comment 2 sits at the ACTUAL `$searchFields` refusal, which is `assertSearchFieldsAreSearchable` in `packages/metadata-protocol/src/protocol.ts` — not in objectql at all, so the PM's assumption that `search-companion.ts:224` might be the second home was falsified. That file turned out to be the better place for the ruling anyway: `assertProjectionFieldsExist`, the projection door's own gate, is 220 lines above it in the same file, so the reader comparing the two axes is standing exactly where the question forms. The PM's other hypothesis was half right — engine.ts:4830 does already name the 400, and the carve-out bullet at :4848 already explains why a non-system caller does not KEEP the column, but neither answers why that is silence rather than a refusal, so I extended rather than duplicated and left search-companion.ts:224 untouched.",
      "tests": "pnpm --filter @objectstack/objectql test -- --maxWorkers=2 → 'Test Files 189 passed (189) / Tests 3356 passed (3356)'. pnpm --filter @objectstack/metadata-protocol test -- --maxWorkers=2 → 'Test Files 75 passed (75) / Tests 1094 passed (1094)'. pnpm --filter @objectstack/objectql typecheck → 'Done' (metadata-protocol declares no typecheck script). npx eslint on both changed files → exit 0. Gates re-derived at implementation time for the REAL two-file surface via `node scripts/pm/dispatch-gates.mjs packages/objectql/src/engine.ts packages/metadata-protocol/src/protocol.ts` — adding protocol.ts pulls in one gate the dispatch list did not have, check:cross-package-test-inputs. All green: check:adr-anchors ('OK (48 anchored file(s), every governing ADR still referenced; ... 22789 citation(s) across 3807 file(s) resolve)', exit 0), check:durability-log-level, check:engine-double-contract ('OK — 166 pinned, 133 in the DEBT ledger, 2 exempt'), check:stack-collection-maps, check:cross-package-test-inputs, check:nul-bytes ('OK (scanned 7336 text file(s) ... no raw ASCII control bytes)'), node scripts/check-engine-split-ratio.mjs. Plus a control-byte self-scan over both changed files (grep -naP over the C0 range + DEL): clean. The objectql suite is unchanged by construction, not just by count: no line was deleted and every added line is a block-comment continuation, so the token stream the compiler sees is identical.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #8080: the 400 that four in-tree comments cite as the `$searchFields` half of this contract is asserted by no test — it follows by composition of two pinned facts (`resolveSearchFields` excludes hidden fields; the gate refuses known-but-unsearchable names), but nothing names `__search` and asserts the code+status envelope on it. Observation-class, `finding` label, no `pm:queue`, unassigned."
      ],
      "comments_verbatim": {
        "packages/objectql/src/engine.ts — stripSearchCompanionFromRead": "   * ## Why this door is SILENT where the `$searchFields` door returns a 400\n   *\n   * Asked on #7876 and ruled there on 2026-08-12 (direction C): the divergence\n   * is intended, and it is not reopenable on symmetry alone. The two doors are\n   * two KINDS of surface.\n   *\n   *  - `$searchFields` is AUTHORING input — it tells the server how to RUN the\n   *    query. A value the server will not honour has to be said out loud, or\n   *    the caller gets a WIDER answer than the one they narrowed to, in a\n   *    response with nothing to distinguish it from a satisfied one. That is\n   *    why `assertSearchFieldsAreSearchable` refuses the name with a 400\n   *    (#4254) — the refusal is protecting the ROW SET.\n   *  - `select` is a READ PROJECTION — it names what the caller would like\n   *    back. Dropping a column the caller may not see leaves the answer\n   *    correct: the rows are still the rows that were asked for, one key\n   *    lighter. {@link omitInternalFields} directly above answers\n   *    `?select=id,key` exactly this way, for exactly this reason (#7728), so\n   *    silence here is the platform's existing read-path rule, not an\n   *    exception to it.\n   *\n   * ⛔ Do not add a refusal here to make the two doors agree. That was option B\n   * on #7876, weighed and declined: it turns a request that answers 200\n   * today into a failure, for tidiness, on a spelling no non-system caller in\n   * this tree uses — the companion's only deliberate reader is the backfill\n   * carved out above. If a REAL caller is ever burned by the silent drop, that\n   * measurement reopens it; the asymmetry by itself does not.",
        "packages/metadata-protocol/src/protocol.ts — assertSearchFieldsAreSearchable": "     * ## The PROJECTION axis answers the same name differently — on purpose\n     *\n     * A name this gate refuses can still be spelled in `select` and come back\n     * 200 with the key simply absent: {@link assertProjectionFieldsExist} gates\n     * on whether a field is KNOWN, not on whether it is RETURNABLE, and the\n     * engine's read path then drops what the caller may not see\n     * (`omitInternalFields` for `internal: true` columns,\n     * `stripSearchCompanionFromRead` for the hidden `__search` companion).\n     * Measured on `__search`: `searchFields=__search` is a 400 here, while\n     * `select=__search` is a 200 whose body lacks it.\n     *\n     * That is not a gap someone forgot to close — it was asked as its own\n     * question and ruled intended on 2026-08-12 (#7876, direction C). The two\n     * axes are different KINDS of surface. `searchFields` is AUTHORING input:\n     * it tells the server how to RUN the query, so a value the server will not\n     * honour changes WHICH ROWS come back — the fail-open this gate exists for.\n     * `select` is a READ PROJECTION: it names what the caller would like back,\n     * the row set is untouched either way, and a column the caller may not see\n     * is simply not in the body.\n     *\n     * ⛔ Do not close the asymmetry by teaching the projection gate to refuse\n     * unreturnable columns. That was the alternative on #7876 and it was\n     * declined: it converts requests that answer 200 today into failures, for\n     * symmetry, on spellings with no measured callers. A real caller burned by\n     * a silent drop reopens the question on THAT measurement; the asymmetry\n     * alone does not."
      }
    }

    Issue body completeness (verified): the fetched body is complete — it ends with the Related: #7642 (origin) · PR #7868 (8b90d68) · #2486 line, and all six sections (question, premises, options A/B/C, recommendation, four-lens, related) are intact with no truncation. The issue carries two comments, not one: the 03:05:16Z maintainer ruling and this seat's 13:56:35Z claim comment.

    Placement decisions, for review: the ruling asked for a comment at "the $searchFields refusal". Located by symbol, that is assertSearchFieldsAreSearchable (metadata-protocol), not anything in objectql — a hidden field is excluded from the searchable set (search-fields.ts:112), lands in the unsearchable bucket and throws 400 INVALID_FIELD; the "is hidden" wording is specifically the auto-default branch's reason, and an object that declares searchableFields gets the same 400 with the declared-set message instead. search-companion.ts:224 was left untouched on purpose: it already narrates both doors in one sentence, and a third copy of that explanation is the drift this card exists to prevent.

    Serial constraints: engine.ts edited at 4856-4882 — the declared neighbours (#5320 at :3813, #7933 at :9257, PR #7989 at :7922-8727) are all far away, and #7933's live worktree shows its engine.ts edit elsewhere. protocol.ts was NOT on the PM's cleared list, so I checked it myself: of the open PRs touching it, #8046's hunks are at :2769/:3157 and #8045's end at :5195 — my insert at :6157 is ~960 lines from the nearest, no overlap at three lines of context.


    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    ACCEPTED on substance — PR #8079. ⛔ Ready-flip held: six jobs are still in_progress.

    Engine-core seat #6019, session_014C8pAprWdmtecFsEprZax4. Verified against GitHub and origin/main, ⛔ not against the report's self-description.

    The dispatch was wrong about where the second door is, and the dev proved it

    I declared the file surface as engine.ts + search-companion.ts, on my own assumption that the 400 lived in objectql. It does not. assertSearchFieldsAreSearchable is in packages/metadata-protocol/src/protocol.ts, and the dev located it by symbol as instructed rather than trusting my coordinates.

    ⭐ What makes the placement right and not merely correct is the detail it went on to find: assertProjectionFieldsExist — the projection door's own gate — sits 220 lines above it in the same file. A reader comparing the two axes is standing exactly where the question forms. That is a better siting argument than the one I dispatched.

    It also handled a serial constraint I did not clear: protocol.ts was absent from my constraints line, so it measured the file itself — open PRs #8046 (:2769/:3157) and #8045 (ends :5195) against its insert at :6157, nearest gap ~960 lines. And it re-derived gates for the real two-file surface, picking up check:cross-package-test-inputs, which my dispatch list did not contain because my file surface was wrong. Both are the dispatch's gaps, closed by the dev.

    Zero behaviour change — structurally, not just by test count

    +54 / −0 across two files, every added line a continuation inside an existing block comment. No line deleted means the token stream the compiler sees is identical, so "the objectql suite is unchanged" is a property of the diff rather than a lucky measurement — the dev said so in exactly those terms, which is the right way to justify an unchanged number. Suites: objectql 189 files / 3356 tests, metadata-protocol 75 / 1094.

    The comments earn their place

    The bar for direction C was never "record that a ruling happened" — it was whether a future reader is stopped. Both comments state the authoring-input vs read-projection reasoning, cite the ruling and this card, decline option B by name, and name the single thing that reopens it (a real caller measured as burned by the silent drop — ⛔ never symmetry alone).

    ⭐ And comment 1 supplies an argument the dispatch did not: omitInternalFields, directly above, answers ?select=id,key the same way for the same reason (#7728). That converts C from "defensible asymmetry" into "the read path's existing rule, applied consistently" — which is a materially stronger thing for the next reader to hit, and it was measured from the code rather than reasoned from the card.

    search-companion.ts:224 was left alone deliberately — it already narrates both doors, and a third copy is the drift this card exists to prevent. Correct call.

    Verified independently

    check reading
    PR first line Fixes #7876 — correct here: this is the whole deliverable, so the merge should close the card
    path face (get_files) packages/objectql/src/engine.ts, packages/metadata-protocol/src/protocol.ts — ⛔ no docs/adr/**, no .claude/skills/**, no skills/**. Clean; the maintainer-merge branch does not apply
    changeset none, skip-changeset labelled — correct for comments-only; ⛔ an empty-frontmatter changeset would have been rejected by check-empty-changeset.mjs
    cross-seat ⚠️ packages/metadata-protocol is domain:metadata's (#6367) — declaration filed there with a veto window before any flip, per declare-and-proceed. It was owed because my surface was wrong, not because the dev drifted
    out-of-scope finding #8080 verified on GitHub: unassigned, finding, observation-class, carries its own re-check command

    Why it is not flipped yet

    Read from the jobs themselves at ~14:5xZ: 20 concluded, 0 failure — ESLint, Build Core, Temporal Conformance, Dogfood 1/3 + Verify CLI, Check Changeset, ADR maintainer approval all success.

    Still in_progress: TypeScript Type Check, Test Core 1/3 · 2/3 · 3/3, Dogfood Regression Gate 2/3 · 3/3. The first of those is the only job that runs check:type-check-debt. This seat's standing commitment 1 exists because it inherited a PR publicly ACCEPTed while exactly that job was red — so the substance verdict is posted now and the flip waits for conclusions, ⛔ not for an absence of visible failures. Next patrol re-reads them; all-green ⇒ flip ready + enqueue SQUASH.

    ⚠️ On #8080, one note for the triage round rather than for this card: the finding is sharper because of this ruling. The 400 is now load-bearing as a contrast — the projection door's silence is documented as correct precisely because the authoring door refuses — so a future change letting hidden columns clear the searchability gate would leave all four comments false with every suite green.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions