Skip to content

test(scripts): gate the filter slot's wire-alias spellings against the normalizer's table (#8002) - #8182

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8002-wire-alias-parity-gate
Aug 12, 2026
Merged

test(scripts): gate the filter slot's wire-alias spellings against the normalizer's table (#8002)#8182
os-zhuang merged 1 commit into
mainfrom
claude/issue-8002-wire-alias-parity-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8002

Adds check:filter-alias-parity — a consistency gate asserting that the filter slot's wire spelling set is identical on its two sides.

The seam

The ONE filter slot has four wire spellings, declared in two packages with different ownership:

  • where / filter come from the spec's RPC_QUERY_ALIAS_SLOTS (packages/spec/src/data/data-engine.zod.ts).
  • filters / $filter are wire-only — no schema declares them. packages/metadata-protocol extends the spec table with them locally (WIRE_QUERY_ALIAS_SLOTS), which is what makes them fold into where.

#7390 added a third reader: packages/rest gates the slot's arity at the querystring ingress (FILTER_SLOT_QUERY_PARAMS, packages/rest/src/query-multiplicity.ts). It derives where / filter from the spec table and names filters / $filter literally, because it cannot derive them.

Nothing reconciled the two literal halves. A fifth wire-only spelling added to the normalizer's table folds correctly and is silently ungated at the ingress: repetition on it falls back to the misdiagnosis #7390 exists to remove — a 400 naming a malformed filter, when every filter the caller sent was fine and the mistake was sending two.

The opposite direction was already covered. #7390 §5 filterSlotSpellingsAreComplete pins FILTER_SLOT_QUERY_PARAMS to exactly the four, so a spelling added on the REST side alone goes red there. This gate deliberately does not duplicate that pin; it covers the half that test cannot reach.

Why a source scan, and why the AST

A runtime import cannot join the two sides, and both reasons were measured before the script was written:

  1. WIRE_QUERY_ALIAS_SLOTS is a module-private const — not exported, so there is nothing to import even from inside the package.
  2. @objectstack/metadata-protocol is a devDependency of @objectstack/rest (workspace:*), so no runtime edge exists between the two packages that could carry the table.

That leaves reading source, and the repo already has a house answer for cross-package source truth: the TypeScript compiler API, used by a dozen sibling gates (check:route-envelope, check:meta-type-normalized, check:kernel-hook-pairs, and others). Regexes were rejected for the reason that would have rotted this gate first — all three declarations are documented in prose that quotes the spellings, and two of the three are IIFEs whose shape a pattern would have to re-learn on every refactor.

The rot rule: an unreadable shape is RED, never green. The failure mode of any source-reading gate is the silent one — a refactor moves the declaration, the reader matches nothing, two empty sets compare equal, and the gate reports a pass over a corpus of zero. Every reader asserts its anchor: a missing declaration, an initializer whose shape is not the one the script knows, an array element that is neither a literal spelling nor the recognized spec-derived spread — each is a loud failure naming the file and what it expected ("a scan that reads nothing cannot report a pass", #4690).

Two hops, not one

WIRE_DOLLAR_ALIASES folds $x into its bare spelling before the slot table folds, so a $-alias whose bare spelling is a filter spelling is itself a filter spelling — one the ingress would have to know about. The normalizer side therefore reads both tables, and the self-test pins that shape. Today that set contributes nothing ($filter is declared on the slot table, deliberately, and no bare filter spelling appears in the pair table), which is exactly why it is worth pinning.

Proof it can fail

Measured on the real tree, not asserted (planted, run, reverted with git checkout --):

Tree stateResult
main as it standsgreen — 4 wire spelling(s) of the "where" slot, identical on both sides
fifth spelling in WIRE_QUERY_ALIAS_SLOTS onlyred, FOLDED BUT UNGATED: where_clause
the same fifth spelling on both sidesgreen — 5 wire spelling(s) ... identical on both sides
fifth spelling in FILTER_SLOT_QUERY_PARAMS onlyred, GATED BUT NOT FOLDED: where_clause

The --self-test covers the same four cases over planted fixtures, plus the two-hop $ alias and four rot shapes (a missing ingress declaration, a normalizer that stops deriving from the spec table, a non-literal ingress element, an empty spec table), plus its own CI wiring. It runs before every gate invocation, per the family convention.

The failure message

It names both sides with their files, prints the symmetric difference in the direction that happened, and says what to do:

 FOLDED BUT UNGATED: `where_clause`
The normalizer folds it into `where`, but the ingress arity gate does not
see it. A repeated `?where_clause=A&where_clause=B` then falls back to the
misdiagnosis #7390 removed: a 400 naming a MALFORMED filter, when every filter sent
was fine and the mistake was sending two.
Fix: add 'where_clause' to `FILTER_SLOT_QUERY_PARAMS`
in packages/rest/src/query-multiplicity.ts, and extend the §5 pin in
packages/rest/src/rest-server-repeated-filter-param.test.ts.

Scope

This is #8002's option 2. Option 1 — hoisting the wire-only vocabulary onto the @objectstack/spec export surface — is out of scope by triage and is not foreclosed: whether the wire vocabulary belongs in the spec at all is a spec-seat question, and these spellings exist precisely because no schema declares them. The gate is the reversible move: no public API, no dependency-graph change. If that hoist ever lands, both sides become derived, the sets stay equal by construction, and this script can be deleted rather than migrated. The script's header says so.

Changes

  • scripts/check-filter-alias-parity.mjs — new gate.
  • package.jsoncheck:filter-alias-parity, in the family's --self-test && run form.
  • .github/workflows/lint.yml — one step in the ESLint job, beside the other source-shape guards.

No published package changes (the root manifest is private), so this carries the skip-changeset label rather than a changeset.

Verification

  • pnpm check:filter-alias-parity — self-test green, gate green.
  • Gate families re-derived against the actual changed paths with node scripts/pm/dispatch-gates.mjs; all green: check:nul-bytes, check:required-contexts, check:workflow-status-functions, check:node-version, check:shard-attestation, check:changeset-gate-self-tests, check:type-check-coverage.
  • npx eslint scripts/check-filter-alias-parity.mjs — clean.

Generated by Claude Code

…e normalizer's table (#8002)
The ONE filter slot has four wire spellings declared in TWO packages:
`where`/`filter` come from the spec's `RPC_QUERY_ALIAS_SLOTS`, while the
wire-only `filters`/`$filter` are declared in `metadata-protocol`
(`WIRE_QUERY_ALIAS_SLOTS`) and named LITERALLY in `packages/rest`, which
gates the slot's arity at the querystring ingress (#7390).
Nothing reconciled them. A fifth wire-only spelling added to the
normalizer's table would fold correctly and be silently ungated at the
ingress, putting repetition on that spelling back on the misdiagnosis
#7390 exists to remove. `#7390 §5 filterSlotSpellingsAreComplete` pins
the REST side alone; the half holding the truth lives in a package that
is only a devDependency of the one that gates the ingress.
A runtime import cannot join the two sides — `WIRE_QUERY_ALIAS_SLOTS` is
a module-private const, and `@objectstack/metadata-protocol` is a
devDependency of `@objectstack/rest` — so the gate reads source through
the TypeScript compiler API, the idiom a dozen sibling gates already use
for cross-package source truth. Every anchor is asserted: a declaration
that moved, an initializer whose shape changed, or an array element that
is neither a literal spelling nor the recognized spec-derived spread is
a loud failure, never an empty corpus reported as a pass (#4690).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WocN37om5bw81JDoEEMA2e
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 6:51pm

Request Review

@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 12, 2026 — with Claude
@github-actionsgithub-actionsBot added ci/cd dependencies Pull requests that update a dependency file and removed skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Aug 12, 2026
@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 12, 2026 — with Claude
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 19:02
@os-zhuang
os-zhuang added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 2473cd2Aug 12, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8002-wire-alias-parity-gate branch August 12, 2026 19:12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: the wire-only filter alias spellings (filters / $filter) are now declared in two packages, with no gate reconciling them

2 participants

@os-zhuang@claude