Skip to content

fix(spec)!: refuse an array in the equality slot at the schema door, in the comparand face's own words (#19889) - #20047

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-19889-filter-schema-door-array-equality
Sep 25, 2026
Merged

os-litant merged 5 commits into
mainfrom
claude/issue-19889-filter-schema-door-array-equality

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19889
Clause-②: no

This carries out ruling 5805248669 (letter A): the schema door refuses an array in the equality slot, with the compile face's own words. Clause-②: no is the claim's line, copied as it stands. The changeset carries Clause-②: no (narrowing) because AGENTS.md makes a narrowing BREAKING and check:adr-0087-registration reads the arm there. Both lines give the same value.

Session session_019c3Hi6ZMU1p6m6aA6Bz45d, branch claude/issue-19889-filter-schema-door-array-equality, base a0920b42dc. Every reading below was taken at head 46e1c81727 unless it says otherwise.

1. Reproduction (before the change, on origin/main a0920b42dc)

input answer
DatasetSchema.safeParse, control filter: { stage: 'won' } success: true
DatasetSchema.safeParse, filter: { stage: ['won','lost'] } success: true
DatasetSchema.safeParse, measure filter: { stage: { $eq: ['won','lost'] } } success: true
FilterConditionSchema.safeParse on both shapes; FieldOperatorsSchema.safeParse({ $eq: [...] }) success: true (all three)
assertListComparandShapes({ stage: ['won','lost'] }) (the compile face) INVALID_FILTER / 400
assertListComparandShapes({ stage: { $eq: [...] } }) INVALID_FILTER / 400

The face's refusal text read, verbatim: The implicit-equality comparand on field "stage" requires a single comparable value, but received an array (["won","lost"]) at where.stage. For "one of these values" use {"$in": […]} (authoring: in); for "the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains), an $or of those for any-of. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

⚠️ The ruling calls it "one constant", but no remedy constant existed. On main the face built the text inline in arrayEqualityComparandError. This PR extracts it rather than copying it (§2).

2. The change

  • packages/spec/src/data/filter-comparand-refusal-text.ts (new, internal). It holds the ONE remedy constant ARRAY_EQUALITY_COMPARAND_REMEDY and the one message builder arrayEqualityComparandMessage, plus shapePreview, the $contains operator and the in spelling row. Those three moved out of the face so both doors render the value and the prescription from one source.
    • Both doors import this module. The face cannot import filter.zod.ts, because of the import cycle the face already documents.
    • ⛔ It is deliberately NOT in the data barrel, following the currency-fraction-digits.ts precedent. Exporting the text would widen @objectstack/spec/data for no reader, which a Clause-②: no card must not do. check:api-surface stays green.
  • The face (filter-comparand-shape.ts) now calls the builder. Its message is byte-for-byte what it was: every existing face pin, and the analytics / mongodb / memory suites, pass unchanged.
  • FilterConditionSchema refuses both arms inside its existing refinement walk:
    • { field: [...] } at the issue path field;
    • { field: { $eq: [...] } } at field.$eq;
    • the empty array too, and inside $and / $or / $not members through their own re-parse.
  • FieldOperatorsSchema.$eq refuses an array comparand. So does its documentation copy EqualityOperatorSchema.$eq, through one shared equalityComparandSchema factory, following the file's own pairing rule (orderingComparandSchema). NormalizedFilterSchema validates against it and refuses too.
  • Reach = the face's, no wider. A field spec with no $ key (a nested-relation or deep-equality condition) is not judged at this door, because the face never descends one. A pin holds both doors to the same answer on that shape (§4, control rows).
  • Not dropped. A refused document fails its parse and nothing is stripped. The pin CONTROL — the same documents with $in publish, and keep their filter proves that accepted means kept.

"The parse-door message equals the compile-face message": what equality holds

⚠️ Stated rather than settled silently. A zod refinement cannot see its absolute location: in zod 4.6.1 the refinement context is the parse payload, with no path. So the schema door does not print the face's at where.FIELD clause. Its issue carries the location as its path instead (filter.stage, measures.0.filter.stage.$eq).

Pin §3 asserts character equality after removing exactly that one clause, and asserts the clause is present exactly once, so the removal is not vacuous. That holds on 8 shapes: implicit and $eq, empty, and under $and / $or / $not.

Printing at where.stage at save time would name a location that does not exist in the saved document, and the wrong one for a combinator member. The $eq operator slot cannot see its field either (the key belongs to the enclosing record). Its pin asserts equality after removing both the on field "stage" clause and the location clause. If the seat reads ruling item 4 as strict byte-equality, the alternative is to print a fixed where. location; I recommend against it for the reason above.

3. Carrier census (ruling item 3)

Schemas that embed FilterConditionSchema directly. Each one refuses now; the right-hand column is measured at head:

schema · key refuses?
DatasetSchema.filter, DatasetMeasureSchema.filter yes (pinned)
DashboardWidgetSchema.filter, GlobalFilterOptionsFromSchema.filter yes (widget pinned)
ReportSchema.runtimeFilter, JoinedReportBlockSchema.runtimeFilter yes (report pinned)
FieldSchema.relatedListFilter, FieldSchema.summaryOperations.filter yes, as a FilterConditionSchema carrier
BlueprintSummaryOperationsSchema.filter yes, as a carrier
AnalyticsQuerySchema.where, and through it AnalyticsQueryRequestSchema.where yes (measured)
DatasetSelectionSchema.runtimeFilter yes (measured)
QuerySchema.where / .having, AggregationNodeSchema.filter, QueryFilterSchema.where yes (where / having measured)
EngineAggregateOptionsSchema.having yes (measured)
Engine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where, DataEngineVectorFindRequestSchema.where no: the union's first arm is an open record, so it parses (measured). The face refuses it at execution.

Schemas that embed FieldOperatorsSchema: NormalizedFilterSchema (through its field-condition record). EqualityOperatorSchema is the documentation copy.

Where the refusal is met. Every one of these was measured at head:

door answer
defineStack (strict) STACK_SCHEMA_INVALID, datasets.0.filter.stage: …
os validate parses ObjectStackDefinitionSchema, read at source
metadata-protocol saveMetaItem, dataset INVALID_METADATA / 422, issue path filter.status; the $in control saved. This was a throwaway probe on the protocol.dashboard-dataset-publish-gate harness and was deleted, tree clean.
REST datasetSelectionRefusal VALIDATION_FAILED / 400, selection.runtimeFilter.stage: followed by the sentence; previously the analytics compiler's INVALID_FILTER / 400
runtime analytics body (handleAnalyticsRequest) VALIDATION_FAILED, where.stage: …; the bridge maps it to 400, pinned in dispatcher-validation-error.real.test.ts

The read path does not re-validate stored rows, so a stored document keeps loading and its next save is refused. ⛔ Nothing is rewritten or dropped.

Stored and shipped instances carrying the shape: 0.

  • Text scan. A brace-matched scan of every filter / where / runtimeFilter / having / relatedListFilter literal in packages/**, examples/**, apps/**, content/docs/** and skills/** read 7785 files and 4709 carrier literals. It found 20 field entries whose value opens an array. 16 are test fixtures; they are its positive control, including the mongodb refusal pin rows. The other 4 are not FilterCondition carriers: client/realtime-api.ts eventTypes and core/PHASE2_IMPLEMENTATION.md plugin-permission filter. There is no YAML-authored carrier block.
  • $eq grep. A grep for $eq followed by an array found 24 lines: prose, MongoDB aggregation $eq expressions, and one door-refusal conformance row.
  • Runtime census.
    • app-crm, app-todo and app-multi-package load through strict defineStack / composeStacks: 0 hits.
    • app-showcase's config imports connector plugins with no dist/ here. Its barrels were parsed with the registered type schemas instead: 22 objects, 4 datasets, 3 dashboards, 4 reports. That gave 0 hits and 0 other failures.
    • A planted { stage: ['won','lost'] } dataset fired once at filter.stage.
  • Deployed datasets, dashboards and reports are NOT MEASURED.

4. Pins, each proven able to fail

filter-equality-array-schema-door.test.ts has 46 tests: §1 FilterConditionSchema refusals, §2 operator slot, §3 same words, §4 controls at both doors, §5 carriers. Each ablation used scripts/ablation-replace.mjs: the anchor hit exactly once, and each leg printed its blob change. After every leg, restore was proven by blob == HEAD be90963c90 and an empty git diff HEAD. The spec tests import from src, so no dist/ leg was needed.

leg mutation result
A implicit arm off 14 red / 32 green (implicit refusals, same-words rows, DatasetSchema filter.stage, widget)
B $eq arm off 9 red ($eq refusals, same-words rows, measures.0.filter.stage.$eq, report)
C operator-slot refinement off 4 red (FieldOperatorsSchema, EqualityOperatorSchema, NormalizedFilter, slot same-words)
D schema door prints a location of its own 5 red (the §3 equality rows)
E reach widened into nested-relation specs 1 red (the nested-relation control)
F operator slot also refuses null 1 red (the scalar/null control)
control restored tree 46 / 46 green; git status clean at 46e1c81727

Re-judged, not dropped: filter-comparand-shape.test.ts pinned FieldOperatorsSchema's array-accepting set as ['$between','$eq','$in','$ne','$nin'], "$eq … only because both are z.any()". $eq has left that set, so the expectation now reads ['$between','$in','$ne','$nin'] and the comment says why.

5. ADR-0087

  • New semantic entry filter-equality-array-comparand-refused-at-save. The changeset marker is registered filter-equality-array-comparand-refused-at-save. The gate read it back as [BREAKING+bang+clause-②-narrowing] registered … (new here …). Every factual claim in it was measured at head (§3).
  • Corrected the unreleased entry filter-equality-array-comparand-refused. At :46-49 it said the analytics normalizer's OBJECT form "still reads as membership" because it "does not route through the shared face". That has been false since fix(service-analytics)!: the analytics where door refuses a list in the equality slot instead of reading it as IN (#19888) #20008. It now states which doors refuse the shape at this release, and with what:
    • the face (parseFilterAST, the engine seam): INVALID_FILTER / 400;
    • the analytics where door, in both spellings: the same, and that door alone also refuses a nested-relation list;
    • the schema door on save: the same sentence, as a parse issue.
      The object form's four old readings are named, and its acceptance criteria point to the new sibling entry.
  • gen:migration-registry regenerated registry.ts, and check:migration-registry is green. gen:upgrade-guide and gen:spec-changes were run and wrote zero diff. Both project steps up to PROTOCOL_MAJOR (17, PROTOCOL_VERSION 17.0.0), so no step-18 entry appears in them yet, this one included. check:upgrade-guide and check:spec-changes are green.

6. Changesets, and two DELIBERATE CORRECTIONS for the seat to confirm

  • New: .changeset/19889-filter-schema-door-array-equality.md. It declares @objectstack/spec: minor, the BREAKING banner, fix(spec)!:, a FROM → TO table and the measured census. The grade follows AGENTS.md: a (narrowing) is BREAKING, and the launch-window convention ships it minor. check-changeset-no-major is green.
  • ⚠️ DELIBERATE CORRECTION 1, .changeset/19757-equality-slot-array-refused.md (pending). Its bullet said: "The schema doors are not touched. FilterConditionSchema still parses { field: [...] } … A document carrying the shape therefore still publishes". That is false in the release both ship in. It now says this change did not touch the schema doors, and that a separate change in the same release does.
  • ⚠️ DELIBERATE CORRECTION 2, .changeset/19888-analytics-implicit-array.md (pending, services lane). Its sentence said: "The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted". That is false for the same reason. It now says the schema refuses the shape on save, except for a list inside a nested relation, which only the analytics door judges.
  • check-empty-changeset is therefore red by design ("DELIBERATE CORRECTION … say so on the PR … get it confirmed"). Neither file is restored from base, because that would republish a false sentence.

7. Tests (at 46e1c81727)

package result
@objectstack/spec (full) 564 files · 16273 passed · 2 todo
@objectstack/spec typecheck (tsc, scripts, test layer) green; check:test-typecheck OK
@objectstack/service-analytics 117 files · 2541 passed
@objectstack/objectql 312 files · 5243 passed
@objectstack/rest 195 files · 3273 passed · 1 skipped
@objectstack/metadata-protocol 188 files (3 skipped) · 2676 passed · 19 skipped
@objectstack/runtime 278 files · 3962 passed · 1 skipped
@objectstack/lint 108 files · 4138 passed
@objectstack/cli, unit layer 224 files · 3158 passed

Notes on the table:

  • cli. The first run's two published-subpath-* pins refused on their prerequisite, "packages/cli is not built". After cli was built they ran: 2 files, 29 passed. The integration layer is declared to CI, since the diff touches no spawn entry or integration file.
  • Build. The build ran through turbo for the closures of the packages above: 56 + 57 tasks, all successful.
  • Lint, narrowed. The 8 touched .ts files were linted with eslint --no-inline-config --format json: 8 files counted from the JSON, 0 errors / 0 warnings, and isPathIgnored is false for each. The config enables no type-aware linting (no parserOptions.project, per its own comment at eslint.config.mjs:328), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint belongs to CI.

8. Gates (at 46e1c81727)

dispatch-gates --commands --repo objectstack-ai/objectstack derived 87 commands. The --ran reconciliation, with an exit code per line, reads: 87 derived, 85 run, 2 NOT-MEASURED, 0 UNRUN.

  • 84 exited 0. These include check:api-surface, check:authorable-surface, check:docs, check:migration-registry, check:spec-changes, check:upgrade-guide, check:doc-authoring ("16302 customer-facing string(s) … clean"; sibling prose ids "no growth"), check:nul-bytes ("scanned 9435 … no raw ASCII control bytes"), check:engine-double-contract and check:cross-package-test-inputs.
  • 1 exited 1: check-empty-changeset. This is the deliberate-correction refusal in §6.
  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Reason: both exit 3, PREREQUISITE NOT MET, because they read the whole workspace's dist/. PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 recorded the same two.
  • @objectstack/spec check:generated reports all 15 artifacts current after the build.

Acceptance notes


Generated by Claude Code

… the face's words

FilterConditionSchema (implicit form and $eq) and FieldOperatorsSchema.$eq
refuse an ARRAY comparand at parse. The sentence is assembled once, in an
internal module both doors import (filter-comparand-refusal-text.ts, kept out
of the data barrel), so the save door and the comparand-shape face print the
same words; the face alone appends its `at <path>` location.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…lot; register it

The pins: both spellings refused at FilterConditionSchema and at the $eq
operator slot; the schema door's message equals the face's with only the
face's location clause removed; scalars, null, Date, $field, the list
operators, $ne and nested-relation specs accepted by BOTH doors; and the
dataset, dashboard-widget and report carriers refusing at their own paths.
The face test's array-valued operator set loses $eq. A new ADR-0087 semantic
entry registers the save-time refusal.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
… registry

filter-equality-array-comparand-refused said the analytics normalizer's
OBJECT form "still reads as membership" because it "does not route through
the shared face". Since the analytics where door routes that form through the
face, the sentence is false; it now says which doors refuse the shape at this
release and with what, the save-time door included.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…adds

The $eq refusal is a custom check z.toJSONSchema() cannot project, so the
published FieldOperators, EqualityOperator and NormalizedFilter schemas stay
wider than the runtime at $eq. Declared, as the ledger requires.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…s it made false

The 19757 changeset said the schema doors still parse the shape and the
document still publishes; the 19888 changeset said the authoring schema still
admits it. Both are false once the schema door refuses it on save, and both
ship in the same release, so each now points at the save-time change and
keeps only what stays true.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • 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))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 adbbc5d01e76ac7ed561dec1c95592320acb8565 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ca1594ded0855f91029cc0b91b02fdee5ede841f — the merge of head 46e1c81727aee80f722f4b4554b01250211bbbfb into base adbbc5d01e76ac7ed561dec1c95592320acb8565, 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 ca1594ded0855f91029cc0b91b02fdee5ede841f && git checkout ca1594ded0855f91029cc0b91b02fdee5ede841f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adbbc5d01e76ac7ed561dec1c95592320acb8565 46e1c81727aee80f722f4b4554b01250211bbbfb && git checkout -B drift-repro adbbc5d01e76ac7ed561dec1c95592320acb8565 && git merge --no-ff 46e1c81727aee80f722f4b4554b01250211bbbfb

node scripts/docs-audit/affected-docs.mjs --json adbbc5d01e76ac7ed561dec1c95592320acb8565

⚠️ 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 adbbc5d01e76ac7ed561dec1c95592320acb8565 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 164/164 CONTRACT_REVIEW_TIER
Head-sha: 46e1c81727aee80f722f4b4554b01250211bbbfb

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 164 transcript turns served at the tier the constant names. First review of this head. The seat's provisional reading of ruling item 4 (the same sentence, with the location carried by the issue path) is judged under (b). For the two DELIBERATE CORRECTIONS (.changeset/19757-equality-slot-array-refused.md, .changeset/19888-analytics-implicit-array.md) this is the same-head at-tier PASS that names each note and judges each changed sentence; the maintainer's standing words in this seat's chat, 「changeset 你看着更新就行」, cover the correction of pending notes. Adopted by the seat 2026-09-24T23:34Z. The record below is the reviewer's, unedited except the two header lines.

① Derived judgments

Inputs read: card #19889 body and all 5 comments (ruling 5805248669, services note 5819407923, claim 5820755955, os-dev-report 5823840715); ruling 乙 5793368540; PR #20047 body, 12 files, diff, 1 bot comment, 0 review comments; PR #20008 (merged 2026-09-24T18:24:47Z, 14add487b4f72f48a6ec772e1779f3ad33934eba) and PR #19882 (merged 14:44:23Z, 753672152851f4da52d85b543f72514467b548ee), both ancestors of the PR base a0920b42dc33efadc483f5112da36cfa53124cbb (git merge-base --is-ancestor yes/yes); report.json is byte-equivalent to comment 5823840715. No worktree or probe was run; every reading is git show/git grep/git diff on refs/review/pr-20047 (= head) and origin/main (5581d3000f27daa19991cf9d13d5ad1ed8cf8913).

(a) Accept set, ruling A items 1–3. FilterConditionSchema is z.record(z.string(), z.unknown()).and(z.object({$and,$or,$not})).superRefine(checkFilterConditionComparands) (filter.zod.ts:1850-1869 at head), so field values are judged only by the walk. The walk (filter.zod.ts:1659-1736): a field entry whose value is an array is refused at depth === 0 with path [key] (1675-1684, empty array included since Array.isArray([])); an operator spec's $eq array is refused at depth === 0 with path [key,'$eq'] (1697-1706); $-keys are skipped (1669) and $and/$or/$not members re-enter FilterConditionSchema (1853-1855), so each member is judged at depth 0 of its own pass with nested issue paths; a no-$ spec recurses at depth+1 (1687-1691) and is not judged. The face assertListComparandShapes (filter-comparand-shape.ts:717-775 at head) walks $and/$or/$not and top-level fields, refuses an array spec (757-759) and $eq array (770-773), and returns on a no-$ spec (764). Reach is identical. Nothing else moves: the $eq arm's continue skips only the $icontains (FILTER_TEXT_COMPARAND_OPERATOR, :1574) and preset checks (PRESET_JUDGED_ORDERING_OPS = $gt,$gte,$lt,$lte, :1564; $between, :1724), none of which ever judged $eq. Scalars, null, Date, { $field } (an operator map whose only op is $field, not $eq), $in/$nin/$between lists, $ne lists and nested-relation specs still pass, and the record-of-unknown returns the document unchanged (pinned §4, expect(result.data).toEqual(where), test:202). FieldOperatorsSchema.$eq and EqualityOperatorSchema.$eq share equalityComparandSchema() (filter.zod.ts:285-289, 297, 1453), z.any().superRefine refusing only Array.isArray(value). The pairing rule exists on origin/main: orderingComparandSchema is used by both copies (filter.zod.ts:411, 423-432 and 1427-1432 at main). EqualityOperatorSchema is composed into no carrier (git grep EqualityOperatorSchema at head: only docblocks and the entry), so its narrowing is beyond the ruling's letter but on the file's declared pairing rule, and it is named in the entry surface and changeset. NormalizedFilterSchema narrows as a consequence: normalizedFieldConditionSchema = z.record(z.string(), FieldOperatorsSchema) (filter.zod.ts:2067-2070). Census: my spot-check git grep -E "(filter|where|runtimeFilter|having|relatedListFilter)\s*:\s*\{\s*\w+\s*:\s*\[" over examples/**, apps/**, packages/**/src/**, skills/** excluding tests at head hits only two comment lines (metadata-core engine-delete-dispatch.ts:295, engine-update-dispatch.ts:661); $eq: [ hits are prose and MongoDB $cond aggregation expressions. Consistent with the report's 0 shipped instances; deployed metadata NOT MEASURED (disclosed). "Refused loudly, never dropped" holds: a custom issue fails the parse, nothing strips a key (no .strip/.catch/.transform on the record side), and no non-test consumer parses FilterConditionSchema directly with a fallback (git grep -E "FilterConditionSchema\.(safeParse|catch|parse)\(" over packages/**/src at main, excluding tests: 0 hits); carriers reach it through their type schemas: metadata-protocol 422 INVALID_METADATA from zod issues (protocol.ts:707, 973), REST selection door 400 VALIDATION_FAILED with selection.FIELD: message (rest/analytics-selection-door.ts:133-171), runtime analytics body AnalyticsQueryRequestSchema.safeParse to 400 VALIDATION_FAILED (runtime/domains/analytics.ts:63-76).

(b) Item 4, message equality. The face's text is byte-identical to origin/main: old inline assembly (${position} requires a single comparable value, but received an array (${shapePreview(value)}) at ${path}. For "one of these values" use {"$in": […]} (authoring: ${inSpellings.join(', ')}); ...UNFILTERED result set.) versus arrayEqualityComparandMessage(value, {op, field, path}) (filter-comparand-refusal-text.ts:133-145) with IN_OPERATOR_SPELLINGS = ['in'] (:38) equal to the old LIST_COMPARAND_OPERATORS.get('$in'); the face always passes field and path (filter-comparand-shape.ts:682), so its position and at path clauses render exactly as before. The schema door passes no path, so the message is the face's minus the at where.FIELD clause; on the $eq slot no field, so minus on field "X" too. Truth at every depth: the implicit arm names the local key ({ field: key }, :1680) and the $eq arm the local key (:1702), which is the correct field for a $and/$or/$not member; the issue path ($and.0.tags, $not.tags.$eq, measures.0.filter.stage.$eq) carries the location. Pin §3 (test:128-157) asserts the face clause is present exactly once and the schema string equals face.message.replace(location, '.') over 8 shapes, and the slot row equals the face minus both clauses. Recommendation A keeps one constant and the same words, and a fixed where. prefix would be false for a saved document and for combinator members; I concur with the seat's provisional reading. One non-blocking observation under ③ on the closing sentence.

(c) Carriers. Engine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where and DataEngineVectorFindRequestSchema.where are z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]) (data-engine.zod.ts:100, 234, 329, 354, 414, 1314 at main), so the open first arm parses the shape. These are runtime call options and a wire request, not stored metadata; item 3 names stored filters and their next save; the face refuses at the engine lowering seam. Correctly outside item 3, and disclosed as position (2) in the entry, the changeset (:25) and the PR body. EngineAggregateOptionsSchema.having is FilterConditionSchema directly (:376) and refuses. The entry's surface list matches the carrier grep at head exactly: dataset.zod.ts:191/343, dashboard.zod.ts:872/1285 (GlobalFilterOptionsFromSchema), report.zod.ts:239 (JoinedReportBlockSchema)/347 (ReportSchema), field.zod.ts:1529/1634 (summaryOperations), solution-blueprint.zod.ts:71, data/analytics.zod.ts:685, api/analytics.zod.ts:549, query.zod.ts:295/522/571, data-engine.zod.ts:376, filter.zod.ts:1911 (QueryFilterSchema.where).

(d) ADR-0087. New entry 18.filter-equality-array-comparand-refused-at-save.ts (id, surface, replacement, reason, acceptanceCriteria all present); marker adr-0087: registered filter-equality-array-comparand-refused-at-save (changeset:46); the gate's breakingDeclaration reads BREAKING + bang + clause-②-narrowing (check-adr-0087-registration.mjs:626-634) and requires exactly one marker, which is present. Every checked sentence is true at head: the reach paragraph (verified above), position (1) (analytics forEachWhereFieldEntry recurses a no-$ spec, service-analytics/src/strategies/filter-normalizer.ts:1588-1591 at main, and the normalizer flattens nested relations to dotted members, :1125, :1425), position (2) (the union above), the two request doors (verified above), 422 INVALID_METADATA. Corrected entry: the "four ways" sentence matches the pre-#20008 behaviour the pending .changeset/19888-analytics-implicit-array.md records at :23-26; "hands each equality-slot list to the shared face" is assertNoListInEqualitySlot calling assertListComparandShapes (filter-normalizer.ts:1655-1663 at main); "that door alone also refuses a list inside a nested-relation condition" is the recursion at :1588. registry.ts: I re-derived the whole semantic:18 region (166 entries) from the entry files at head using the generator's own rules (build-migration-registry.ts:179-183 leading // run, :185-191 literal, :253 sort by id, :258-266 four-space indent plus trailing comma) and it is byte-identical to the region between the markers at head (8663 lines each, REGISTRY semantic:18 REGION == DERIVED). refused sorts before refused-at-save, as emitted.

(e) Changesets. New note: "@objectstack/spec": minor, fix(spec)!: summary, **BREAKING** banner, Clause-②: no (narrowing), marker; every sentence checked is true at head (reach, carriers, both request doors, "strips nothing", $ne unjudged, the three dropped-refinement sites). Correction 1, .changeset/19757-equality-slot-array-refused.md:35: the old sentence ("FilterConditionSchema still parses { field: [...] }... still publishes") is false at head; the new one is true (same words minus the path-borne location; the sibling entry exists). The rest of the note stays true: the table row and :48 describe the before-state, :25 "the drivers themselves are untouched" and :34 "no $ key... not descended into" hold. Correction 2, .changeset/19888-analytics-implicit-array.md:30: the old "The authoring schema still admits the shape, so such a document still publishes" is false at head; the new sentence is true, including "One position is judged only here: a list inside a nested relation... still publishes" (schema door depth greater than 0 is unjudged; analytics recurses). No other pending note at head or at main states the schema door admits this shape: .changeset/20010-analytics-where-face-arms.md:26 ("The authoring schema still admits the shapes") concerns null list members, null ordering comparands, blank/null $between endpoints and a scalar $in, none of which this PR refuses; 18232's "schema door" lines concern date ranges. check-empty-changeset refuses both edits by name as the DELIBERATE CORRECTION class (script:121-133; CI annotations on Check Changeset name both files with that remedy). Clause-② consistency: readClause2Line treats the arm as optional (clause2-line.mjs:80-101); check-changeset-no-major reads the PR body's value (no, level axis not engaged) and check-adr-0087-registration reads the changeset's arm (narrowing, breaking, marker required and present). Same value, consistent for both readers.

(f) packages/spec/src/data/filter-comparand-refusal-text.ts is imported only by filter-comparand-shape.ts:311 and filter.zod.ts:9; absent from data/index.ts (grep: 0), from package.json exports, and from every api-surface/*.json (data.json records names only, EqualityOperatorSchema (const) unchanged; api-surface-signatures.json records define* signatures only). No api-surface file is in the diff. Baseline: the ledger's own description says it is hand-edited with no gen: script and build-schemas.ts fails until the line moves; at head the map holds 207 entries and 574 sites (computed), equal to measured (207/574), which is exactly what scripts/dropped-refinements.test.ts:369-371 pins; the three added sites are the three narrowed $eq slots, on the same site pattern as the existing $in/$nin rows.

(g) Pins. 46 tests (9+3+11+19+4) matches 46/46. Leg arithmetic against the file: A (implicit arm off) = §1 5 implicit rows + the two-slot test + §3 5 implicit rows + the remedy test + §5 filter.stage + widget/report = 14; B ($eq off) = 3+1+3+2 = 9; C (slot off) = §2 3 + §3 slot row = 4; D (location printed on the implicit arm) = the 5 implicit §3 rows = 5; E = the one implicit nested-relation control; F = only the operator slot accepts every scalar and null parses $eq: null through FieldOperatorsSchema = 1. Each leg's count is what the file's structure produces, so the pins guard the arms and the reach.

② Semver level

@objectstack/spec: minor with **BREAKING**, fix(spec)!: and Clause-②: no (narrowing) is what AGENTS.md:1072-1084 at main prescribes (a (narrowing) is BREAKING; check-changeset-no-major forbids major in the launch window; a breaking changeset must carry FROM-TO and exactly one ADR-0087 marker, both present). No public export added or moved. Correct.

③ Boundary flags

  • non-blocking: the closing sentence of the shared remedy ("The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set") is a query-time statement now printed at save time; it is not false (a refused filter is unapplied), it is what item 1's "SAME remedy text" orders, and the module docblock (:90-93) states the reason. Recorded, not a defect.
  • non-blocking: the FieldOperatorsSchema.$eq slot drops on field "X" as well as the location clause (disclosed in deviation 1); every stored carrier goes through the FilterConditionSchema arm, which names the field, so only a direct operator-map parse (and NormalizedFilterSchema, whose issue path carries the field) sees the shorter sentence.
  • non-blocking: PR body carries Clause-②: no (the claim's line) while the changeset carries no (narrowing); AGENTS.md:1084 says the changeset "also carries the PR's Clause-② line". Same value, arm optional, gates consistent.
  • non-blocking: files outside the claim's named surface are packages/spec/dropped-refinements.baseline.json (gate-prescribed hand-edit) and .changeset/19888-analytics-implicit-array.md (services lane's note, DELIBERATE CORRECTION); both disclosed and the second awaits a person's confirmation on the PR, per the gate's own remedy.
  • non-blocking, out-of-scope finding confirmed real: { account: { region: ['a'] } } in a dataset or measure filter passes the schema door (depth greater than 0 unjudged, mirroring the face) and is refused at chart time by the analytics door (recursion at filter-normalizer.ts:1588). It is the same publish-clean-fail-later class on the analytics-only carriers; refusing it at the shared door would refuse a deep-equality comparand the face and engine accept, so it needs its own decision (carrier-specific refinement or the analytics door stopping its descent). Disclosed in the entry (position 1), the new changeset (:24) and the 19888 correction.
  • non-blocking: main has moved 10 commits past base a0920b42dc (report said 8), none touching any file in this diff (git log a0920b42dc..origin/main -- diff files: empty); registry.ts untouched on main since base. 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 landed in that window and does not change any sentence here.
  • CI at head, read twice: first fetch 32 runs, 12 success / 16 in progress / 3 skipped / 1 failure; re-fetch 16 success / 12 in progress / 3 skipped / 1 failure. The failure is Check Changeset (run 36071687724), whose annotations name exactly the two corrected notes with the COLLISION-vs-DELIBERATE-CORRECTION remedy; red by design until confirmed. Successes include Type Check · source gates, Spec property liveness, Governed Surface Queue Guard, Check PR Size, the claim/branch guards. Test Core 1-6, Lint and Repo Gates, Type Check workspace/consumer/debt, Dogfood 1-3, Temporal Conformance were still in progress; not awaited, no gate family re-run. PR is a draft, mergeable_state: blocked.
  • The report's suites, gates and ablation logs are claims; the ablation counts and the registry are the two I could re-derive, and both hold.

Implemented-by: claude/issue-19889-filter-schema-door-array-equality
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants