Skip to content

fix(spec): lower equality triples with a $field comparand to {$eq: ref} (#7597) - #7672

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7597-equality-field-ref-lowering
Aug 11, 2026
Merged

fix(spec): lower equality triples with a $field comparand to {$eq: ref} (#7597)#7672
os-zhuang merged 1 commit into
mainfrom
claude/issue-7597-equality-field-ref-lowering

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#7597

Executes the maintainer ruling recorded on #7597 (2026-08-11 08:08:19Z):

Ruling: fix the lowering.parseFilterAST lowers an equality triple whose comparand is a FieldReferenceSchema to { field: { $eq: ref } } — the spelling already implemented on both evaluation paths. ⛔ Do NOT change the memory evaluator's unknown-operator posture; #6520's decision stands. Single-sink change per #5158.

The defect, re-measured on origin/main @ cc3555e

parseFilterAST lowered one authored intent two ways, depending only on how the operator was spelled:

authoredlowered toin-memory rows (fixture: 1=10/5, 3=7/7, 6=null/null)
['amount', '>', { $field: 'budget' }]{"amount":{"$gt":{"$field":"budget"}}}["1"]
['amount', '=', { $field: 'budget' }]{"amount":{"$field":"budget"}}[] ← silent
['amount', '==', ref] / 'equals' / 'eq'{"amount":{"$field":"budget"}}[] ← silent

All four $eq spellings dropped the operator, because a LITERAL comparand's implicit-equality form is { field: value } — right for a literal, and for a field reference it yields a field spec whose only key is $field. Every consumer reads an all-$ key set as an OPERATOR SPEC, and nothing implements $field as an operator.

The full spelling set was measured, not assumed.AST_OPERATOR_MAP folds exactly =, ==, equals, eq onto $eq, and convertComparison's implicit-equality branch intercepts exactly those four — so the two lists coincide, and all four are fixed. A new test sweeps the whole exported VALID_AST_OPERATORS vocabulary and fails if ANY spelling lowers a { $field } comparand to a bare field spec, so a fifth $eq spelling entering the map cannot re-open this at a name the tests never heard of.

What changed

One sink, one branch: in convertComparison's equality shorthand, a comparand that is a FieldReferenceSchema lowers to { field: { $eq: ref } }; everything else keeps the implicit form.

Unchanged, deliberately:

Compile-surface enumeration

Every face of this predicate, with its conclusion. Measured on origin/main @ cc3555e, before and after.

#surfaceconclusion
1driver-sqlapplyFilterCondition (sql-driver.ts) — inherited by driver-sqlite-wasm and local-mode driver-tursoAlready conformant; now exercised. Post-#5222 it compiles { $eq: ref } to a column-to-column comparison. The lowered shape is what this PR routes to it, and the new authoring arm proves it row-for-row against the memory evaluator on both drivers. Docblock updated + one refusal pin re-authored (below).
2turso RemoteTransport buildWhereSQL (remote-transport.ts)Already conformant, unchanged.{ $eq: ref } reaches serializeComparand, fails the bind allow-list, and uncompilableComparand recognises isFieldReference and refuses with INVALID_FILTER naming cross-field comparison and the repair. The bare form was also loud there (unsupportedOperator). Loud before, loud after.
3service-analytics compileScopedFilterToSql (read-scope-sql.ts)Out of scope — not reachable from this change, and owned elsewhere. Both call sites (native-sql-strategy, objectql-strategy) pass it the RLS read SCOPE, which is compiled from policy/CEL and never passes through parseFilterAST; no array sugar reaches it. Its own $field posture is #7598's card (#7604 was closed as its duplicate). Untouched here.
4service-analytics lowerAnalyticsWhere + buildNode (filter-normalizer.ts)Inherits the fix at the lowering, and shifts loud→silent at its own compiler — reported, not fixed here. See the measurement below.
5formulamatchesFilterCondition (matches-filter.ts)Already conformant; it is the reference implementation.resolveValue resolves { $field } against the record and $eq's arm answers it, NULLs included. Measured: the four spellings now return ["3","6"] — the corpus's declared $eq row set — where they returned [] before.
halfobjectql having-filter.tsmatchesHavingOut of reach — having is never lowered through this sink.QueryASTSchema.having / DataEngine's having are declared as a FilterCondition OBJECT, metadata-protocol says so in as many words (having — "a FilterCondition OBJECT — the engine has no AST lowering for it"), and engine.ts hands ast.having straight to applyHaving. Nothing this PR produces can arrive there; its current bare-$field refusal (unknownOperator) is untouched. Stated explicitly because this face has no conformance-table coverage.
frozendriver-memory / driver-mongodbOut of scope per the #5499 investment freeze. Measured anyway, for the record: memory-matcher.ts's $eq arm is value != target, so an object comparand answers false for every row — the same silent bucket $gt with a reference has been in since #3948. The bare form was refused there by the shape gate, so this face sees the same loud→silent shift as face 4. Not edited: the freeze is explicit, and the fix is one decision for the whole $field-in-analytics/memory residue rather than a rider on this card.

Face 4, measured (the one shift this PR causes)

authored ['amount','=',ref] BEFORE → { amount: { $field: 'budget' } }
→ THROW INVALID_FILTER/400 "Unsupported filter operator \"$field\" on \"amount\""
AFTER → { amount: { $eq: { $field: 'budget' } } }
→ leaf { member: 'amount', operator: 'equals', values: [ { $field: 'budget' } ] }
authored ['amount','>',ref] BEFORE and AFTER (unchanged, pre-existing)
→ leaf { member: 'amount', operator: 'gt', values: [ { $field: 'budget' } ] }

assertCompilableComparand refuses a $field only in the LIKE-family and $in/$nin member positions; a scalar operator's object comparand falls through to toSqlBindValue, which JSON-stringifies it (its own docblock: "any other object / array → JSON text. Not a meaningful comparison on any column"). So at this face the equality spelling moves from a loud refusal into the same silent bucket the ORDERING spellings have occupied all along — it is not a new defect class, it is one more spelling entering an existing one, and the shift is the price of making = behave like > everywhere else.

It is reported rather than fixed here on the precedent this exact surface already set: #7604's triage records that #5222's dev seat declined to widen a security-relevant acceptance surface in a second subsystem as a side effect, and the PM called that the right call. Refusing only $eq there would create a fresh asymmetry inside the analytics face; refusing all six is #7598's card, whose open question 1 is whether these emitters implement $field or refuse by design. The measurement is being posted to #7598 so its executor has it.

Re-pricing #7596 (asked explicitly by the dispatch)

Untouched — neither easier nor harder, and still necessary.

#7596 removes FieldReferenceSchema from the $between endpoint unions and rules $in/$nin members out. This PR changes only the SCALAR equality position; parseFilterAST's in / nin / between lowerings pass their comparand through exactly as before, so nothing this PR produces changes what arrives in a list position, and no test added here asserts anything about those unions.

Two facts that help #7596 without changing its scope:

Tests

  • packages/spec/src/data/filter-field-reference-lowering.test.ts (new) — the four spellings, case folding, the reference passing through unmodified (dotted paths included), literal comparands keeping implicit equality, a non-$field object comparand, a non-string $field, the sugar's own nesting (and / or / legacy flat), isFilterAST unmoved, and the vocabulary sweep.
  • cross-field-conformance-cases.ts gains CROSS_FIELD_AUTHORED_CASES — the corpus's AUTHORING arm, entering through the sink instead of at the already-lowered object: 4 spellings × 3 storage classes, a > control, and two sugar-structure cases. Each asserts the lowered SHAPE and then holds the lowered filter to the same both-paths-same-rows rule as every existing case. Run by driver-sql (per dialect cell) and driver-sqlite-wasm.
  • sql-driver-cross-field-reference.test.ts — the bare-form refusal pin used to DERIVE its input from parseFilterAST, which is precisely the route this PR closes. It is now hand-authored (the refusal itself is unchanged and still pinned), and a sibling case pins that the triple no longer lowers to it.

Reverse verification

Direction predicted before running: removing the fix (via a patch file, restored with git apply) should turn the AUTHORING arm and the new triple pin RED, and leave the hand-authored bare-form refusal GREEN — the point of having re-authored it. Measured, exactly that: 14 authoring cases + the equality TRIPLE no longer lowers to that bare spelling failed with expected { amount: { '$field': 'budget' } } to deeply equal { amount: { '$eq': … } }; the > control and the bare-form refusal stayed green.

Gates run locally

gateresult
@objectstack/spec build + full suite377 files / 9880 tests passed
@objectstack/driver-sql full suite86 files passed, 4 skipped / 1399 passed, 50 skipped
@objectstack/driver-sqlite-wasm full suite24 files / 374 tests passed
@objectstack/formula full suite25 files / 631 tests passed
typecheck (spec, driver-sql, driver-sqlite-wasm)clean
pnpm check:driver-conformanceOK — 37 covered cells, 3 DEBT, 0 exempt
pnpm check:merge-driver / check:adr-anchors / check:spec-parsed-aliasOK
pnpm --filter @objectstack/spec check:authorable-surfaceOK — 1276 defaults unchanged, no anchor diff
node scripts/check-nul-bytes.mjsOK — 7069 files, no control bytes

Consumer sweep: grepped every package and app for a test pinning the old equality-triple lowering ('=' , { $field …, and the == / equals / eq spellings) — the only hits are the files in this diff.

Changeset: @objectstack/specminor, @objectstack/driver-sqlminor. Minor rather than patch on both counts honestly — a previously-silent authored shape gains working semantics and the sink's output object changes, matching the sibling precedent #7536 took for the same function; driver-sql gains a public (test-only DATA) export. driver-sqlite-wasm's change is test-only, so it carries no bump.


Generated by Claude Code

…f} (#7597)
`parseFilterAST` lowered one authored intent two ways depending only on the
operator spelling: `['amount', '>', { $field: 'budget' }]` kept its operator
and worked on both evaluation paths, while `['amount', '=', ref]` — and its
`==` / `equals` / `eq` spellings — dropped it and produced
`{ amount: { $field: 'budget' } }`, a field spec whose only key is `$field`.
No backend reads that as an equality: the in-memory evaluator dispatches
`$field` to its operator switch, finds no arm, and returns its fail-closed
`false`, so the filter silently matched no record on the very path that
produced it.
An equality triple whose comparand is a `FieldReferenceSchema` now lowers to
the explicit `{ field: { $eq: ref } }` — the spelling both paths already
implement (memory resolves the reference; driver-sql compiles it to a
column-to-column comparison, #5222). Single-sink change per #5158.
Unchanged, deliberately: a LITERAL comparand keeps implicit equality (the
branch is on the comparand, not on the operator), a `$field` carrying a
non-string is not a field reference on any path and keeps the literal
lowering, and the evaluator's unknown-operator posture stays as #6520 left
it — a hand-authored bare `{ field: { $field } }` FilterCondition keeps its
current fate on every backend.
Tests: the cross-field conformance corpus gains an AUTHORING arm entering
through the sink instead of at the already-lowered object, run by both SQL
drivers; `packages/spec` gains the lowering pins plus a vocabulary sweep that
fails if ANY operator spelling lowers a reference comparand to a bare field
spec. driver-sql's bare-form refusal pin is re-authored by hand, since the
array sugar no longer reaches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkfGjiPTZjvhjE2fSuWdBW
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 11, 2026 10:11am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 11, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 11, 2026 10:35
@os-zhuang
os-zhuang added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit 6a9dec6Aug 11, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7597-equality-field-ref-lowering branch August 11, 2026 10:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[spec] parseFilterAST lowers ['a', '=', { $field: 'b' }] to a bare field spec no backend reads as equality

2 participants

@os-zhuang@claude