Skip to content

fix(spec)!: a filter carrying a comparand the query faces refuse is refused when it is saved (#20116) - #20247

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20116-filter-save-door-face-parity
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20116-filter-save-door-face-parity

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20116

Clause-②: no

Summary

FilterConditionSchema, the save door behind every stored filter, now refuses every comparand slot the query faces refuse. The dataset filter and measure filter also refuse those slots inside a nested-relation condition, through #20207's walk. This closes every member the collector lists. The remainder named below is new members this run found, so this PR says Part of, not Fixes.

The judge is the shared comparand-shape face itself (assertListComparandShapes), called read-only per slot. So the save door refuses exactly what the face refuses on every query, and passes what it passes. The two boolean flags, which that face does not judge, are refused on the predicate every flag face uses: the comparand is not a boolean.

Measured before (origin/main af32cf9a)

For every member, FilterConditionSchema, DatasetSchema.filter, a dataset measure filter, a dashboard widget filter and a report runtimeFilter all answered success: true. The query faces answered as below. Probe run from the worktree; "face" is assertListComparandShapes, "analytics" is normalizeWhereComparands.

member face (top / $and) face (nested) analytics (top / $and / nested)
{ stage: { $null: 'x' } }, $exists: 'false', $null: null, $exists: 1 accept (flags are not its arm) accept INVALID_FILTER / 400, every position
{ amount: { $gt: null } }, $lte: null 400 accept 400, every position
{ stage: { $in: 'won' } }, $nin: 'won' 400 accept 400, every position
{ stage: { $in: ['won', null] } }, $nin: [null] 400 accept 400, every position
{ amount: { $between: [null, 5] } }, 5, [1], [1, 2, 3], ['', 5], [{ $field: 'a' }, 5] 400 accept 400, every position
{ stage: { $ne: ['won', 'lost'] } }, $ne: [] 400 accept 400, every position

Controls answered accept on every door and face: $null: true, $exists: false, $ne: null, $eq: null, $gt: { $field }, $ne: { $field }, $in: [], $nin: [], $between: [1, 5], $between: [' ', 'M'], $in: [{ $field }], $gt: true.

What changes

The words, per arm (every new or changed refusal text, quoted)

arm issue path sentence source
array in the equality slot, implicit or $eq stage, stage.$eq unchanged: arrayEqualityComparandMessage, shared with the face
array under $ne (route A) stage.$ne arrayInequalityComparandMessage with the field, the face's sentence less its location
null ordering comparand amount.$gt read off FieldOperatorsSchema's slot for the same comparand
null $in / $nin member stage.$in.1 read off the slot
null, blank or { $field } $between endpoint amount.$between.0 read off the slot
non-list $in / $nin stage.$in NEW: the face's nonListComparandError sentence less at PATH; the slot has only zod's generic wording
$between that is not a pair amount.$between NEW: the face's malformedRangeComparandError sentence less at PATH
non-boolean $null / $exists stage.$null NEW: driver-sql's first sentence word for word, then the analytics door's reason and prescription

The texts as printed (FilterConditionSchema.safeParse):

stage.$null ← { stage: { $null: "x" } }
Operator "$null" on field "stage" requires a boolean comparand (true or false). Received a string ("x"). @objectstack/spec FieldOperatorsSchema declares $null as a boolean, and a non-boolean is refused rather than coerced because the backends read one in OPPOSITE directions — one as IS NULL, another as IS NOT NULL. Write the boolean itself: "$null": true matches rows whose "stage" has no value, "$null": false rows whose "stage" has a value. The filter was NOT applied.

stage.$exists ← { stage: { $exists: "false" } }
Operator "$exists" on field "stage" requires a boolean comparand (true or false). Received a string ("false"). @objectstack/spec FieldOperatorsSchema declares $exists as a boolean, and a non-boolean is refused rather than coerced because the backends read one in OPPOSITE directions — one as IS NULL, another as IS NOT NULL. Write the boolean itself: "$exists": true matches rows whose "stage" has a value, "$exists": false rows whose "stage" has no value. The filter was NOT applied.

stage.$in ← { stage: { $in: "won" } }
Operator "$in" on field "stage" requires an ARRAY of values. Received string ("won"). "$in" tests membership of a list — write ["won"] for a single value, or use "=" ($eq) to compare against it. Authoring spellings: in. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

amount.$between ← { amount: { $between: 5 } }
Operator "$between" on field "amount" requires a [min, max] value array. Received number (5). A range needs exactly two bounds, in order; the authoring spelling that lowers to "$between" is "between". The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

stage.$ne ← { stage: { $ne: ["won","lost"] } }
Operator "$ne" on field "stage" requires a single comparable value, but received an array (["won","lost"]). For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

amount.$gt ← { amount: { $gt: null } }   (the operator slot's existing sentence)
null is not a valid $gt comparand. null is not ordered, and no two evaluation faces agree on what an ordering against it matches (driver-memory's live path reads two absences as equal; its reference matcher compares through JS coercion). State absence with the null predicate instead: {"$eq": null} is "has no value", {"$ne": null} is "has a value". Ruled 2026-09-01: a null ordering comparand is refused at the validation entrance.

stage.$in.1 ← { stage: { $in: ["won", null] } }   (the operator slot's existing sentence)
null is not a valid $in member at index 1. No two backends agree on what a null in a list-comparand position matches (the SQL family answers a NULL under NOT IN unconditionally; the JS matchers split over the two readings of "no value"). State absence explicitly with the null predicate instead: {"$or": [{"$in": […]}, {"$null": true}]} is "one of […] OR has no value", and {"$null": false} is the has-a-value half. Ruled 2026-08-31: a null list member is refused at the validation entrance.

amount.$between.0 ← { amount: { $between: [null, 5] } }   (the operator slot's existing sentence)
null is not a valid $between endpoint at index 0. (… the same null-member sentence as above …)

amount.$between.0 ← { amount: { $between: ["", 5] } }   (the operator slot's existing sentence)
A blank value is not a valid $between endpoint at index 0 (the MIN bound). A closed interval [min, max] requires BOTH endpoints present and non-empty: an empty string is not an interval endpoint at any backend — it is compared as a value, so the range stops bounding on that side while still reading as a complete range. Write the bound you meant; and if only ONE side is genuinely bounded, that is not a range at all — drop $between and write the side you have as a scalar comparison ({"$gte": min} for a lower bound, {"$lte": max} for an upper one). Ruled 2026-09-17: a blank $between bound is refused at the validation entrance.

amount.$between.0 ← { amount: { $between: [{ $field: "floor" }, 5] } }   (the operator slot's existing sentence)
A { "$field": … } reference is not a valid $between endpoint at index 0. No evaluation path resolves a field reference inside a list: the in-memory evaluator (matchesFilter) leaves the list unresolved and compares the raw reference OBJECT, so it silently matches nothing, and both SQL drivers refuse the position with INVALID_FILTER / 400. Write a literal value here, or move the reference to a scalar comparison operator ($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be. Ruled 2026-08-11: declared = enforced (ADR-0049).

A nested member on a dataset carrier prints the same sentence as its top-level form, with the leaf field named, at its own path, for example filter.acct.stage.$in.1.

The changeset also carries a FROM → TO table for the HTTP doors. POST /analytics/dataset/query answers a one-bound $between in selection.runtimeFilter with 400 VALIDATION_FAILED, located on the member, instead of 400 INVALID_FILTER from the normalizer, and POST /analytics/query refuses the same shape in where at its request schema. It also carries rows for the producer's two spellings, $in: [null, ""] and $nin: [null, ""].

Changed docblocks

What stays accepted (pinned, both doors)

The null predicate ($eq: null, $ne: null), $null / $exists true and false, and a { $field } reference as the whole comparand of $eq / $ne / $gt / $gte / $lt / $lte. Also a column-to-column range as two bounds, $in: [] / $nin: [], $in with a { $field } member (the face does not judge members), a whitespace or falsy $between endpoint, and relation traversal with no operator. On the shared schema (every carrier but the two dataset ones), any member shape INSIDE a nested relation stays accepted: neither the face nor the drivers' flag checks descend one.

Remainder, named (why Part of)

Every member the collector lists (5854575239, 5854887743, and this card's own) is closed. This run measured two more positions of the same family:

  1. Nested-relation forms on a dashboard widget filter and a report runtimeFilter. Both reach the analytics where door, which refuses every member inside a relation: dataset-executor.ts sends combineFilters(compiled.filter, selection.runtimeFilter) to it. Both still save the shape, because fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207's carrier refinement sits on the two dataset carriers only (triage record 5825670610). The same gap holds for A list inside a nested-relation condition in a dataset or measure filter ({ account: { region: ['a'] } }) passes the save-time schema door and is refused only when the chart runs #20080's own equality-list shape on these two carriers. Closing it means applying the same refinement to DashboardWidgetSchema.filter and the report runtimeFilters, which are outside this order's file surface.
  2. The comparand-TYPE face's refusals. normalizeFilterComparandTypes refuses a plain-object or Map comparand, for example { stage: { $eq: { a: 1 } } } or { stage: { $in: [{ a: 1 }] } }. The analytics door refuses both, in every position, and every save door accepts both. The collector's enumeration names the shape face's tables and the flag arm, not the type face, so this PR does not move it.

Producer census (Zone 2 answer 4)

A literal-comparand git grep -P for each member shape, with a lit control per shape, over non-test, non-doc files:

tree member hits that are authored filters control hits ($null / $exists boolean, $in: [, $between: [)
this repo examples/** at af32cf9a 0 0 / 3 / 0
this repo packages/** at af32cf9a 0 (every hit is prose, a type table or an operator map) 87 / 230 / 40
objectui at the pin f8a9d0fb05 1 producer: packages/fields/src/widgets/FilterConditionField.tsx:240-241 25 / 12 / 2
cloud main 48d70663ab 0 (one code comment) 0 / 11 / 1

The producer is objectui's filter-condition widget. condToMongo writes "is empty" as { [field]: { $in: [null, ''] } } and "is not empty" as { [field]: { $nin: [null, ''] } }. Both are offered by default for text, number, date, select and lookup fields. field.form.ts puts this widget on relatedListFilter and on a rollup's summaryOperations.filter, both FilterConditionSchema carriers, and it also edits sys_sharing_rule.criteria_json. The face has refused a null list member on every query since the 2026-08-31 ruling, so such a filter already fails its related list or rollup. After this PR, the Studio save is refused instead, at the slot, with the $or / $null prescription. No D2 conversion: the 2026-08-31 ruling declined to give a null list member any meaning, so a conversion would have to invent one. The producer fix is objectui's and is reported to the PM for routing. Blind spot: a multi-line literal or a runtime-built comparand is not matched by a line grep.

Tests

All heavy runs went through scripts/pm/os-verify-lock.sh. Each reading names the tree it was taken on.

run tree reading
pnpm --filter @objectstack/spec build, check:generated, typecheck c300e80e (final) exit 0, exit 0, exit 0
@objectstack/spec full suite (vitest run --maxWorkers=2) c300e80e (final) 579 files, 16731 passed, 2 todo
@objectstack/spec full suite 70e9a610 plus the regenerated registry (31ddfa40) 577 files, 16700 passed, 2 todo
the two pin files (filter-save-door-face-parity.test.ts, dataset-filter-nested-relation-list.test.ts) 70e9a610, after the ablations were restored 149 passed
consumer: @objectstack/service-analytics full suite (the where door) 70e9a610 128 files, 3022 passed
consumer: @objectstack/lint full suite (validate-chart-bindings and the dataset readers) 70e9a610 109 files, 4232 passed
derived gates (dispatch-gates.mjs --commands, 87 lines) c300e80e 85 exit 0; 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:type-check-debt, both need the whole-repo build); --ran reconciliation: 87 accounted, 0 unrun

Patch round (review 5856977110, CI Test Core (4/6) at c300e80e). packages/rest/src/analytics-filter-refusal-envelope.test.ts asserted the one-bound $between as 400 INVALID_FILTER from the normalizer. DatasetSelectionSchema.runtimeFilter and AnalyticsQueryRequestSchema.where are FilterConditionSchema, so the route's schema door now answers first. Readings on ab146a9a (the tree batch F ran on; the batch header reads dfca00f0 because the last commit, a test and changeset edit, landed before its spec step): rest full suite (--project local) 201 files, 3576 passed, 1 skipped; the flipped file 31 passed; service-analytics full suite 129 files, 3041 passed; spec full suite 581 files, 16770 passed, 1 todo (was 2: the $ne §5 todo is now a pin); spec check:generated exit 0; derived gates 87 derived, 85 exit 0, 2 exit 3 PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:type-check-debt), --ran 87 accounted. Consumer sweep: a git grep -P for every refused member shape over all non-spec test files found 62 files. Only this one drives a request or schema door (DatasetSchema or DatasetSelectionSchema on /analytics/dataset/query, AnalyticsQueryRequestSchema on /analytics/query, ObjectSchema at registration) with a refused shape. Every other hit is an engine where, an RLS scope, a driver input or a comment.

Pin sweep. Two published-behaviour pins flipped and were rewritten to assert the new semantics with their substance:

Ablation (reverse verification), one-off, no permanent file. Each leg went through scripts/ablation-replace.mjs WRAP mode from the committed tree 70e9a610. The anchor hit exactly 1 time (x1 → x0, replacement x0 → x1), and the restore was proven by blob hash equal to HEAD with an empty git diff HEAD:

leg mutation result on the two pin files
1 the face's verdict dropped (if (face && false)) red: 95 failed / 54 passed, both files
2 the flag rule dropped red: 18 failed / 131 passed (the flag rows and the four §1 tables)
3 the shared walk's reach widened into nested relations (depth === 0 removed) red: 30 failed / 119 passed. More diagnostics, not fewer: nested slots are refused twice, and the shared reach pins go red
4 the carrier walk's operator-map arm dropped red: 28 failed / 121 passed (nested $eq / §5 rows)
restore none 149 passed

Acceptance notes

  • The face's docblock still names the schema door's twins as "nullOrderingComparandMessage (./filter.zod.ts)" and so on. Those builders did not move; the new module reads the same sentences off FieldOperatorsSchema's slots. No edit to the face was needed or made.
  • FieldOperatorsSchema.$in / $nin / $between / $null / $exists still print zod's generic wording for a non-list, a malformed range and a non-boolean flag. FilterConditionSchema prints the pointed sentences above. Pointing the operator slots too is polish, and is not done here.
  • The four sentences read off the operator slot name the operator and the index but not the field, because the slot cannot see the field. The issue's path names it.

Generated by Claude Code

… the query faces refuse

The save door asks the comparand-shape face about each slot of a field entry
(read-only) and refuses what it refuses, in the schema door's words, plus a
non-boolean $null / $exists flag, which every query face refuses.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
… the ADR-0087 entry and changeset

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…comparands-refused-at-save

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
… refuse every face-refused slot inside a nested relation

The judge moves to a non-barrel module so the dataset carriers' nested-relation
walk asks the same function FilterConditionSchema's walk asks.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 24 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in comparandShapeFaceRefusal))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in comparandShapeFaceRefusal))
  • content/docs/data-modeling/analytics.mdx (via DatasetMeasureSchema (symbol, a top-level const))
  • content/docs/data-modeling/queries.mdx (via FilterConditionSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FilterConditionSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/query-syntax.mdx (via FilterConditionSchema (symbol, a top-level const), INVALID_FILTER (literal, a string literal in comparandShapeFaceRefusal))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in comparandShapeFaceRefusal))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in comparandShapeFaceRefusal))

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.

What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08c8484a191f39684a0ace3565f84a7313684f5e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ab42804770462b9d577290acabe020b81457c056 — the merge of head ab146a9a3f4e3aa077594ba66b86880bb4ff933e into base 08c8484a191f39684a0ace3565f84a7313684f5e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ab42804770462b9d577290acabe020b81457c056 && git checkout ab42804770462b9d577290acabe020b81457c056
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08c8484a191f39684a0ace3565f84a7313684f5e ab146a9a3f4e3aa077594ba66b86880bb4ff933e && git checkout -B drift-repro 08c8484a191f39684a0ace3565f84a7313684f5e && git merge --no-ff ab146a9a3f4e3aa077594ba66b86880bb4ff933e

node scripts/docs-audit/affected-docs.mjs --json 08c8484a191f39684a0ace3565f84a7313684f5e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08c8484a191f39684a0ace3565f84a7313684f5e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…s schema door now

The row moves from the normalizer-refusal table to the #17551 door table,
asserting 400 VALIDATION_FAILED, the member path and the face's sentence; the
changeset gains the HTTP-door FROM -> TO rows.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c300e80e6ec4efda74fea52e6e63db5bee38e61d

① Derived judgments

  • ①1 Parity, exact both ways — RIGHT at the claimed reach. Corpus executed with tsx on packages/spec/src at the head (worktree /home/user/objectstack-review-20247) and at origin/main 0d3ec471 (/home/user/objectstack-review-20247-main): every declared operator of FieldOperatorsSchema.shape (18) × 47 comparand shapes (the report's battery plus Map, bigint, { $field: 42 }, [null, ''], [undefined, 5], ['', null], [null, null], a cyclic object, an object whose toJSON and toString throw) plus the implicit slot, at 8 positions (top, $and, $or, $not, $and.$or.$not, one-hop relation, two-hop relation under $or, $and inside a field spec), on 7 carriers (FilterConditionSchema, Dataset.filter, Dataset.measure.filter, DashboardWidget.filter, Report.runtimeFilter, Query.where, Query.having). The face verdict (assertListComparandShapes, INVALID_FILTER only) OR the flag rule (typeof !== 'boolean' on $null / $exists) OR the pre-existing $icontains arm was compared against the door's issues under the slot path. Result at the head: 0 mismatches in 49,280 cells; 304 refused cells per combinator position on every carrier, 304 inside a relation on the two dataset carriers, 42 inside a relation on the shared-reach carriers (all the $icontains text arm, pre-existing), 0 at acct.$and.0.f on every carrier (the face does not descend $and inside a field spec either). origin/main → head flips: 0 refuse→accept; every accept→refuse cell is a member of the declared set ($ne array, null ordering, non-list $in / $nin, null list member, malformed $between, null / blank / undefined / { $field } endpoint, non-boolean flag) — no shape the face accepts is refused. The flag rule refuses an explicit $null: undefined although FieldOperatorsSchema.$null is .optional(); this matches every driver face (driver-memory memory-driver.ts:1688, driver-mongodb mongodb-filter.ts:1158, driver-sql sql-driver.ts:4638-4643 hasOwnProperty && typeof !== 'boolean', analytics filter-normalizer.ts:2057), is not JSON-reachable, and is not a narrowing beyond the faces. Carrier census by git grep FilterConditionSchema at the head (non-test): dataset filter and measure filter (ui/dataset.zod.ts), widget filter (ui/dashboard.zod.ts:872) and options-source filter (:1285), report runtimeFilter (ui/report.zod.ts:239, :366), field relatedListFilter (data/field.zod.ts:1542) and rollup summaryOperations.filter (:1647), blueprint summary filter (ai/solution-blueprint.zod.ts:71), analytics query where (data/analytics.zod.ts:685), dataset selection runtimeFilter (api/analytics.zod.ts:551), query where / having and aggregation filter (data/query.zod.ts:295, :522, :571), data-engine having (data/data-engine.zod.ts:376) and the data-engine where unions whose first arm is an open record (:100, :234, :329, :354, :414, :1314, unchanged). The changeset's surface list names each of these. Two out-of-reach members (nested relations on Widget / Report; comparand-TYPE face) are correctly declared and appended to the collector in 5856822806.
  • ①2 Producer safety — RIGHT; one producer, already filed. objectstack at the head, examples/** + packages/** non-test, non-doc, literal-comparand git grep for every member shape (TS and JSON/YAML spellings): 0 authored filters; every hit is a docblock, a conformance table or an operator map (memory-analytics.ts:81, filter-normalizer.ts:526). objectui at the pin f8a9d0fb05 (.objectui-sha at head and at origin/main): 1 producer, packages/fields/src/widgets/FilterConditionField.tsx:240-241 condToMongo writes { $in: [null, ''] } for isEmpty and { $nin: [null, ''] } for isNotEmpty, offered by default for text, number, date, select and lookup (filter-builder.tsx:1024-1029); the reader at :380-385 folds the same shape back. datasetFilterCondition.ts:165 writes $exists: false / true (boolean, accepted; a control). No other writer of a member shape at the pin. cloud main 48d70663: 0. Count refused by the new rule: 2 stored shapes from 1 producer, both refused at FIELD.$in.0 / FIELD.$nin.0 with the null-member sentence (probed). Filed as objectui#10790 (open, confirmed by API). No unnamed producer.
  • ①3 Sentences — RIGHT. Probed at the head on FilterConditionSchema: $ne array prints arrayInequalityComparandMessage with the field (Operator "$ne" on field "f" requires a single comparable value, but received an array (["won"]). For "none of these values" use {"$nin": […]} …); non-list $in / $nin and a malformed $between print the face's nonListComparandError / malformedRangeComparandError sentence less at where.PATH (test §2 asserts equality after removing the clause exactly once); null ordering, null member / endpoint, blank and { $field } endpoint print the FieldOperatorsSchema slot's own message (nullOrderingComparandMessage, nullListComparandMemberMessage, blankRangeBoundMessage, listPositionFieldReferenceMessage, filter.zod.ts:465-860; test §2 asserts byte equality with FieldOperatorsSchema.safeParse at the same index); the flag sentence opens with driver-sql's first sentence word for word (sql-driver.ts:4235) and continues with the analytics door's reason and prescription (filter-normalizer.ts:1999-2006) less location and history. 0 of the corpus's refusals carry at where.; 0 slots carry more than one issue. Paths point at the slot: f.$in.1, f.$between.0, f.$null, f.$ne, f (implicit), and per carrier filter.stage.$in.1, measures.0.filter.…, widgets.0.filter.…, runtimeFilter.… (test §3). $between: [undefined, 5] lands on the slot's blank sentence at index 0 (the union's error arm at filter.zod.ts:835-841), so no arm falls back to the face's located text. $in: undefined prints write [null] for a single value because shapePreview([undefined]) stringifies to [null]; the face prints the same bytes, so it is consistent, if odd, and pre-existing.
  • ①4 Judge module — RIGHT. filter-save-door-refusals.ts is not exported from packages/spec/src/data/index.ts (grep: no line names it) and package.json exports names barrel subpaths only, so it is unreachable from the published surface; the diff vs merge-base 615c4687 touches no api-surface/, export-origins/ or dropped-refinements file and CI Build Core, Spec property liveness and Type Check · source gates are green. Cycle reasoning is sound: filter.zod.ts → filter-save-door-refusals.ts → filter-comparand-shape.ts / filter-comparand-refusal-text.ts, neither of which imports filter.zod.ts; importing FieldOperatorsSchema back would close the cycle, so handing it in is the right shape. Throw behaviour: comparandShapeFaceRefusal reads only code === 'INVALID_FILTER' and rethrows anything else, so the door never misreports a face defect as a refusal; probed: an object whose toJSON and toString both throw escapes safeParse (from shapePreview's String(value) fallback) on 14 cell classes at the head vs 5 at origin/main ($eq, implicit, $icontains) — the same absurd object crashes the face on any query, and no JSON-reachable input throws. A cyclic filter object throws RangeError in the dataset carriers' walk at head and at main alike (the fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207 walk has no depth bound; pre-existing, not this PR's).
  • ①5 $not and depth — RIGHT. The face recurses into $not (filter-comparand-shape.ts:820-823) and the door re-parses $not: FilterConditionSchema.optional(), each member restarting checkFilterConditionComparands at depth 0, so combinator depth is unbounded on both sides and the corpus shows identical verdicts at $not and $and.$or.$not. The PRESET_WALK_MAX_DEPTH = 32 guard applies only to non-$ nesting, where the face-parity arm is gated to depth === 0 anyway; the face has no depth guard to respect. No double report: 0 slots with more than one issue in the corpus; the multi-slot document reports exactly $or.0.owner.$ne, amount.$between.0, amount.$gt, stage.$in, stage.$null; the dataset document with one top-level and one nested refused slot reports exactly two issues, and the carrier walk skips every entry not inside a relation (dataset.zod.ts:210), so the shared walk and the carrier walk never both fire on one slot.
  • ①6 Pins — WRONG: two published-behaviour pins in packages/rest are flipped silently and the head is RED. The spec pins themselves assert substance: §1 asserts verdict EQUALITY per derived cell (and more than 40 refused per position), §2 asserts code: 'custom', the slot path and the sentence per arm (byte equality with the slot or with the face less location), §3 asserts the same message on all four carriers at filter.SLOT / measures.0.filter.SLOT / widgets.0.filter.SLOT / runtimeFilter.SLOT, §5 the nested table on both dataset carriers; the filter.test.ts flip asserts age.$between.1 and slot-message equality (correct); the fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207 §4 control-row flip asserts INVALID_FILTER / 400 at the analytics door and the carrier message equal to the door's less its location (correct). BUT packages/rest/src/analytics-filter-refusal-envelope.test.ts (present since fix(service-analytics)!: the analytics where door runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032 at 246314df, so at the dev's base af32cf9a) sends runtimeFilter: { amount: { $between: [10] } } through POST /analytics/dataset/query and pins 400 INVALID_FILTER (:206, :221), and pins that { amount: { $between: [10] } } must still pass AnalyticsQueryRequestSchema (:296, :301); its docblock table (:239-247) states the same. This PR flips both (the changeset itself declares the REST/runtime doors now answer VALIDATION_FAILED before the compiler) and rewrote neither. CI at the head: Test Core (4/6) completed failure — @objectstack/rest 2 failed | 3574 passed: a $between with one bound → 400 INVALID_FILTER (AssertionError: expected 'VALIDATION_FAILED' to be 'INVALID_FILTER', line 221) and CONTROL — the four spellings the schema PASSES still cross the seam ({"amount":{"$between":[10]}} must still pass the schema: expected false to be true, line 301); job 108640113023, run 36326463265. The dev report's NOT MEASURED note claims no fixture in either package carries a member shape (grep over packages/**/*.test.ts …); that claim is false, and the seat ACCEPT 5856822806 was written on 16 in progress / 0 failing. The final check set on c300e80e is 31 success / 6 skipped / 2 failure (Test Core, Test Core (4/6)).

② Semver level

  • minor + BREAKING — RIGHT for the launch window. scripts/check-changeset-no-major.mjs forbids a major bump outside pre-mode and records that an accept-set narrowing ships minor until GA, with the **BREAKING** banner and the ADR-0087 disposition as the carriers. The changeset carries "@objectstack/spec": minor, **BREAKING**, a fix(spec)!: summary and Clause-②: no (narrowing); breakingDeclaration() in check-adr-0087-registration.mjs reads all three signals, so the disposition is demanded and present. CI Check Changeset is green.
  • Claim / body Clause-②: no vs changeset no (narrowing) — coherent, with a note. The level axis reads the PR body (readClause2Line on the event payload): a bare no stands the axis down; no (narrowing) would enforce minor+ on a moved package, which @objectstack/spec: minor satisfies either way, so no gate verdict differs. The ADR-0087 gate reads the arm in the changeset, where it is. Precedent: .changeset/19889-filter-schema-door-array-equality.md:44 carries the same no (narrowing) line. AGENTS.md (:1085-1086) says the changeset body also carries the PR's Clause-② line, which reads as the same line; the PR body copies the claim's arm-less no, so the two texts differ by the arm. Not blocking; a body edit to Clause-②: no (narrowing) would make them one line.
  • FROM → TO — one measured shape is missing a row. The table covers the flags, null ordering, non-list $in, $in with a null member, null / blank / { $field } endpoints, malformed $between and $ne list; the REST/runtime code move and the data-engine open-record where are declared. The producer census names TWO stored shapes from objectui#10790, $in: [null, ''] and $nin: [null, '']; the table and the entry's replacement prescribe only the $in half (an $or of an $in and a $null true). The $nin-with-null rewrite ("none of these AND has a value": an $and of a $nin and $null: false) is stated nowhere in the changeset; the refusal sentence read off the slot carries only {"$null": false} is the has-a-value half. Not blocking on its own, since the prescription reaches the author at the refusal, but the changeset is what an upgrading agent greps and this is the one measured producer shape.
  • registered disposition — RIGHT. filter-query-face-comparands-refused-at-save is new in the diff (entries/semantic/18.…ts) and resolves in the regenerated registry.ts at the head; the dev's check:migration-registry / check:spec-changes / check:upgrade-guide exit 0 and CI Check Changeset is green.

③ Boundary flags

  • Part of #20116, two members appended — consistent. Every member in 5854575239 and 5854887743 and the card's own is closed at the face's reach; M-widget (nested relation on Widget filter / Report runtimeFilter) and M-type (normalizeFilterComparandTypes) are appended in 5856822806; the corpus confirms Widget / Report accept every member inside a relation at the head (the 42 nested refusals on those carriers are the pre-existing $icontains arm).
  • objectui#10790 (open, confirmed). Nothing goes red on it: the only objectui test naming the shape at the pin, FilterConditionField.operators.test.ts:328-330, asserts condToMongo's output and never parses it through a spec schema; Console Pin Gate (ci.yml:2332) builds the client closure and the vendored console and runs no objectui tests, and it is skipped on this PR (no console path moved); the dogfood gates ran green on the head and examples/** carries 0 member shapes. The seat's ruling that the PR does not wait for the producer fix is consistent with the 2026-08-31 null-member ruling.
  • FieldOperatorsSchema generic wording. $in / $nin non-list, $between arity and $null / $exists non-boolean still print zod's generic text at the operator slot while FilterConditionSchema prints the pointed sentence; declared in the PR's Acceptance notes as polish, and the operator slot is not this PR's surface.
  • Stale it.todo. packages/spec/src/data/filter-ne-array-schema-door.test.ts:248-254 still records that FilterConditionSchema does NOT refuse $ne: [...] on save and that the arm was Reported to the seat; this PR makes that text false and did not touch it (one of the run's 2 todo). Not a flipped pin; a doc drift to sweep.
  • REST docblock drift. packages/rest/src/analytics-filter-refusal-envelope.test.ts:234-256 states { amount: { $between: [10] } } passes the schema on both routes; false at the head. Part of blocking item 1.
  • Pre-existing, not this PR's. The fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207 carrier walk has no depth bound and recurses without limit on a cyclic filter object (RangeError at head and at origin/main); the shared walk is bounded at 32. Not JSON-reachable.

Implemented-by: claude/issue-20116-filter-save-door-face-parity
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: FAIL

  1. Two published-behaviour pins in packages/rest are flipped by this PR and were not swept, and the head is red on them: packages/rest/src/analytics-filter-refusal-envelope.test.ts :206-221 (a $between with one bound → 400 INVALID_FILTER, now answers VALIDATION_FAILED) and :294-303 (CONTROL — the four spellings the schema PASSES still cross the seam, { amount: { $between: [10] } } is now refused by AnalyticsQueryRequestSchema), plus the docblock table at :239-247. CI Test Core (4/6) at c300e80e fails on exactly these two (job 108640113023). The dev report's NOT MEASURED note for the rest / runtime doors rests on the false claim that no fixture carries a member shape; the flip is the very code move the changeset declares, so the pins must be rewritten to the new posture (VALIDATION_FAILED, located on selection.runtimeFilter.amount.$between, and the control list reduced to the three spellings the schema still passes) with the docblock corrected, and the head must be green before enqueue.

…n null-member rows

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ab146a9a3f4e3aa077594ba66b86880bb4ff933e

① Derived judgments

Delta review of c300e80e → ab146a9a. git diff --stat c300e80e ab146a9a touches exactly three files of this PR's own (packages/rest/src/analytics-filter-refusal-envelope.test.ts, packages/spec/src/data/filter-ne-array-schema-door.test.ts, .changeset/20116-filter-save-door-face-parity.md); everything else in that range is origin/main 0d3ec471 arriving through merge f3fee77d. No source file of the PR moved, so the round-0 judgments ①1–①5 (parity over the 49,280-cell corpus, producer census, sentences, judge module, $not / depth) and the spec-side half of ①6 carry over unchanged to this head.

  • ①1 Blocking item 1 — CLOSED, RIGHT. The a $between with one bound row left the [#5352] REFUSALS table (400 INVALID_FILTER, message match only) and sits in the [#17551] AT_THE_DOOR table with member: 'selection.runtimeFilter.amount.$between' and an anchored sentence (^Operator "\$between" on field "amount" requires a \[min, max\] value array\. Received array \(\[10\]\)\. A range needs exactly two bounds). The loop asserts statusCode === 400, code === 'VALIDATION_FAILED', code !== 'INVALID_FILTER', code !== 'ANALYTICS_QUERY_FAILED', the details.fields[] entries whose field equals the member filtered to a length of exactly 1, that entry's message matching the sentence, and no at where. in it (:262-297 at the head). Those are the face's bytes, located on the member: probed at the head with tsx on packages/spec/src, DatasetSelectionSchema.safeParse({ …, runtimeFilter: { amount: { $between: [10] } } }) yields exactly one issue, custom @ runtimeFilter.amount.$between, whose message is the face's malformedRangeComparandError sentence less its location; the door prefixes selection. (analytics-selection-door.ts:151) and fieldsFromZodIssues → zodIssuesToFields copies issue.message through (spec/src/api/zod-issues-to-fields.ts:198), so the pin measures the wire. Docblock true: the [#17551] table row now reads refused at the schema (#20116) on BOTH routes, which is what both schemas answer (probed: AnalyticsQueryRequestSchema refuses at where.amount.$between, DatasetSelectionSchema at runtimeFilter.amount.$between, same sentence); "a fourth joined them" matches four AT_THE_DOOR rows; the [Decision] DatasetSelection is a published wire shape with no Zod schema, so four of its members have no door at any layer — where should DatasetSelectionSchema live, and who authors it? #17551 sentence about the three rows that changed because the dataset route was the looser one is preserved and the $between row is explained separately as FilterCondition itself narrowing, which is the truth. service-analytics: object-form analytics where skips the shared comparand-shape face's other arms ($in null member, $gt: null, null/blank $between bound, scalar $in) that the FilterArray spelling refuses 400 #20010 note stays true: it moved with the row and now says the face's sentence has named the shape since fix(service-analytics)!: the analytics where door runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032 and still does, and that only WHERE moved (schema door, sentence less at where…), which round-0 ①3 established byte-for-byte. Sibling-schema control right: it iterates AT_THE_DOOR, maps selection.runtimeFilter. → where. for the member row, and asserts exactly one issue at where.amount.$between matching the same sentence; probed: exactly one issue at that path. Pass-list control: three spellings { stage: {} }, { $nor: [{ stage: 'won' }] }, { $or: [] }, each probed success: true on AnalyticsQueryRequestSchema. Executed: worktree /home/user/objectstack-review-20247b at ab146a9a, pnpm install --frozen-lockfile exit 0, pnpm --filter @objectstack/rest... build exit 0, npx vitest run --project local src/analytics-filter-refusal-envelope.test.ts → 1 file, 31 passed, 0 failed.
  • ①2 Delta elsewhere — nothing interacts. (a) The merge brought RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212 (plugin-security RLS null family), fix(cli): refuse a present non-array packages in the stack-collection and docs readers #20231 (cli readers), fix(service-analytics): judge each read scope with the engine's own admission before composing it #20232 (service-analytics read-scope admission) and fix(spec)!: refuse scale on a currency inline grid column; prefix promises no default symbol (#20045) #20223 (spec currency inline column). Their test files were read for a schema door fed a member shape: plugin-security/src/rls-null-comparand-fails-closed.test.ts:125 parses PermissionSetSchema, whose rowLevelSecurity[].using is a CEL string (spec/src/security/rls.zod.ts:393), not a FilterCondition; its control at :256 sends { status: { $in: ['open', null] } } and { $gt: null } through engine.find(OBJ, { where }) expecting INVALID_FILTER / 400 — the engine judges where with the face (objectql/src/engine.ts:976 assertListComparandShapes) and no spec schema, so that expectation is untouched by this PR; rls-compiled-comparand-faces.test.ts and lint/src/validate-rls-predicate-enforceability.test.ts carry no member shape. fix(service-analytics): judge each read scope with the engine's own admission before composing it #20232's objectql-read-scope-engine-admission.test.ts:78 parses DatasetSchema with no filter, and its scope { region: { $in: ['emea', 'apac', 'amer'] } } is legal. fix(spec)!: refuse scale on a currency inline grid column; prefix promises no default symbol (#20045) #20223's tests are FieldSchema / InlineGridColumnSchema on scale; fix(cli): refuse a present non-array packages in the stack-collection and docs readers #20231's are cli readers. Repo-wide at the head, of 336 non-spec test files importing a spec schema, 18 carry a member shape, and each drives a driver, the engine, the analytics normalizer or the read-scope compiler — never a FilterCondition carrier's safeParse — except the rest file above. (b) git merge-tree --write-tree origin/main ab146a9a against CURRENT origin/main 08c8484a (four commits past the merged 0d3ec471: a91d12af fix(spec)!: a flattened view overlay is judged by the member its viewKind names (#20186) #20245, 5983d112, e0f17a37, 08c8484a) exits 0 with tree f30a8499, no conflict. (c) Registry drift zero. At the head pnpm --filter @objectstack/spec check:migration-registry → src/migrations/registry.ts is current (265 semantic, 214 retired-key, 199 retired-def); gen:migration-registry rewrote it with an empty git status; every entries/semantic/*.ts id resolves in registry.ts (0 missing), the four view.hidden and view.owner are declared on the strict ViewItem authoring door and stored verbatim, but nothing in either repo reads or writes them — and check:liveness cannot see it, because its view walk stops at the container arm #20085 ViewItem / ViewItemWire retired keys from the merge sit at registry.ts:18844-18879, and the six semantic slugs new since the merge base (inline-grid-column-currency-scale-refused, manifest-version-semver-2-0-0, package-manifest-version-grammar-enforced, package-version-row-semver-2-0-0, plugin-version-semver-2-0-0, this PR's filter-query-face-comparands-refused-at-save) each appear exactly once. On the merge-tree result against current main the same --check reports current at 266 semantic (the +1 is a91d12af's), so the textual merge drops nothing.
  • ①3 Changeset additions — RIGHT. (a) HTTP-door table: the route is POST {basePath}/analytics/dataset/query (rest-server.ts:12055), which hands selection to datasetSelectionRefusal (:12113; analytics-selection-door.ts:137-161) and answers 400 { code: 'VALIDATION_FAILED', message, details: { fields } } with every field path prefixed selection. (the root re-spelled selection). POST /analytics/query is the runtime's (dispatcher-plugin.ts:1178 → domains/analytics.ts:116-119), whose assertAnalyticsQueryBody runs AnalyticsQueryRequestSchema.safeParse (:63) and lifts any non-date-range issue set to 400 VALIDATION_FAILED + details.fields[] (:76-82, via validationFailure in types/src/validation-failure.ts); probed, the schema issue is exactly one, at where.amount.$between, in the face's sentence — so "refused by the request schema at where.amount.$between, answered 400 VALIDATION_FAILED" is true. Row 2's scope ("top level or in $and / $or / $not") is the face's reach the round-0 corpus measured; a member INSIDE a nested relation in selection.runtimeFilter still passes the schema and is refused INVALID_FILTER by the analytics where door, which the changeset's "shared reach" paragraph already states. (b) TO spellings, probed at the head on FilterConditionSchema (door) and assertListComparandShapes (face): FROM { stage: { $nin: ['lost', null] } } door stage.$nin.1 / face INVALID_FILTER 400 → TO { stage: { $nin: ['lost'], $null: false } } door accept, face accept; FROM { $in: [null, ''] } door stage.$in.0 / face 400 → TO { $or: [{ stage: { $null: true } }, { stage: '' }] } accept / accept; FROM { $nin: [null, ''] } door stage.$nin.0 / face 400 → TO { stage: { $null: false, $ne: '' } } accept / accept; controls $ne: '' and $eq: '' alone accept on both, and FieldOperatorsSchema accepts { $null: false, $ne: '' }. Meaning: sibling operators on one field are conjoined on every face — driver-memory collects each operator as a write and promotes a contested key to an $and branch on the same field (memory-driver.ts:118-152, "both constraints survive"), the SQL where-builder's node is the AND of its entries — so $nin: ['lost'] ∧ $null: false is "has a value and is not lost", $null: true ∨ = '' is "no value or empty string" (the builder's "is empty"), and $null: false ∧ $ne: '' is "has a value and is not the empty string" (the builder's "is not empty"). The FROM shapes never had one set (the 2026-08-31 ruling: no two backends agree on a null list member), so each row states the intent it rewrites, which is the right form. Round-0's ② note that the $nin-with-null rewrite was missing is closed.
  • ①4 §5 pins — substance, RIGHT. issueAt fails on success and on any count but exactly one issue at the dot-joined path. Pin 1: the face refuses { stage: { $ne: ['won', 'lost'] } } with INVALID_FILTER / 400 and its message contains at where.stage.$ne. exactly once (split length 2); the door's issue at stage.$ne has code 'custom', a message byte-equal to the face's with that clause replaced by ., opens with the $ne sentence, contains REMEDY verbatim (For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin).) and ends with the NOT-applied sentence. Pin 2: DatasetSchema refuses filter: { $or: [{ stage: { $ne: [] } }] } at filter.$or.0.stage.$ne with REMEDY. Control: $ne: null and $ne: 'lost' parse with data equal to the input. The stale it.todo round-0 flagged is gone. Executed: npx vitest run src/data/filter-ne-array-schema-door.test.ts at the head → 1 file, 28 passed, 0 todo.
  • ①5 CI at the new head — FINAL, GREEN. Read off GET /commits/ab146a9a…/check-runs after the last shard completed: 42 check-runs, 37 success / 5 skipped / 0 failure / 0 in progress. The round-0 red is closed: Test Core (4/6) completed success at 15:44:47Z and the Test Core aggregate completed success at 15:50:46Z (run 36329759228); the other five shards, Build Core, Lint & Repo Gates, Spec property liveness, Check Changeset (both runs), Type Check · source gates / consumer gates / debt ledger / workspace, TypeScript Type Check, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (aggregate and 3 shards), Dogfood Verify CLI, Governed Surface Queue Guard, the four claim / card gates and filter are all success. The 5 skipped are Auto Label (second run), Build Docs, Check PR Size (second run), Console Pin Gate (no console path moved) and Packed-tarball smoke (opt-in), the same skip set as at c300e80e. Nothing red, nothing still running. The API reports mergeable: true, mergeable_state: blocked (branch-protection review requirement, not a check).

② Semver level

  • minor + BREAKING — unchanged, RIGHT. The changeset still carries "@objectstack/spec": minor, the **BREAKING** banner, fix(spec)!:, Clause-②: no (narrowing) and the HTML-comment disposition adr-0087: registered filter-query-face-comparands-refused-at-save; the entry resolves in the regenerated registry.ts (drift zero above). Check Changeset is green on both of its runs at this head. Round-0's note that the PR body's bare Clause-②: no differs from the changeset's no (narrowing) by the arm still holds (the dev left it verbatim); no gate verdict differs, not blocking.
  • FROM → TO — complete now. The three rows round-0 asked for ($nin: ['lost', null], the producer's $in: [null, ''] and $nin: [null, '']) are present and each TO passes both doors (①3). The HTTP-door table names the code move INVALID_FILTER → VALIDATION_FAILED at both routes, status unchanged, field named in both.

③ Boundary flags

  • Round-0 flags closed: the stale it.todo in filter-ne-array-schema-door.test.ts (now real pins); the REST docblock drift ([#17551] table row corrected on both routes, service-analytics: object-form analytics where skips the shared comparand-shape face's other arms ($in null member, $gt: null, null/blank $between bound, scalar $in) that the FilterArray spelling refuses 400 #20010 note moved and true); the missing $nin null-member FROM → TO row.
  • Part of #20116 — unchanged; the two appended members (nested relations on Widget / Report; comparand-TYPE face) stay open on the collector; no new member this round.
  • objectui#10790 — unchanged, open; the changeset now carries the producer's two rewrites, so an upgrading author can grep them.
  • origin/main moved after the merge. The head merges 0d3ec471; main is at 08c8484a, four commits on. Not re-merged (the dev declined a second locked re-verification); merge-tree is clean and the registry check passes on the merged tree, and the queue rebuilds on main. Nothing in those four commits touches a FilterCondition carrier or its tests (#20245 view overlay, docs titles, CEL TSDoc, masked-read field types).
  • Nested relation inside selection.runtimeFilter still passes DatasetSelectionSchema and is refused INVALID_FILTER by the analytics where door; the changeset's HTTP-door row 2 scopes itself to top level and combinators, and its "shared reach" paragraph declares the rest. Consistent, not a drift.
  • PR body Clause-②: no vs changeset no (narrowing): differs by the arm, as in round 0; not blocking.
  • Pre-existing, not this PR's: FieldOperatorsSchema's generic wording for a non-list $in / $nin, malformed $between and non-boolean flag; the unbounded fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207 carrier walk on a cyclic object.

Implemented-by: claude/issue-20116-filter-save-door-face-parity
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 15:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit cfc3bcf Sep 27, 2026
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants