Repository navigation
Commit 6aa3188
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
#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 #19882 recorded the
same two.
- `@objectstack/spec check:generated` reports all 15 artifacts current
after the build.
## Acceptance notes
- **Residual (finding, not fixed here).** A list inside a
nested-relation condition on a dataset or measure filter, `{ account: {
region: ['a'] } }`, still passes `DatasetSchema`. Since #20008 the
analytics `where` door refuses it (`INVALID_FILTER` / 400, measured).
Refusing it at the shared schema door would refuse a deep-equality
comparand the shared face and the engine accept. That is a different
decision, and it is left to the seat.
- **Error code at two request doors.** An analytics request carrying the
shape now answers `VALIDATION_FAILED` / 400 at the request door (REST
dataset selection, runtime analytics body) instead of `INVALID_FILTER` /
400 at the compiler. Both are class-1 400s carrying the same sentence.
No test pinned the old code for this input: the `rest`, `runtime` and
`service-analytics` suites are green.
- The published JSON Schema cannot state the check (`z.toJSONSchema()`
has no custom-check projection). Three sites are declared in
`dropped-refinements.baseline.json`: `data/FieldOperators` `$eq`,
`data/EqualityOperator` `$eq` and `data/NormalizedFilter`
`…valueType.$eq`, and its `measured` counts moved 206→207 schemas and
571→574 sites.
- `$ne` carrying a list is untouched. #19886 and the face's `it.todo`
carry it. #19886 remains open.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66ac73d commit 6aa3188
12 files changed
Lines changed: 797 additions & 76 deletions
File tree
- .changeset
- packages/spec
- src
- data
- migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | | - | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
| 5 | + | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
605 | 605 | | |
606 | 606 | | |
607 | 607 | | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
608 | 613 | | |
609 | 614 | | |
610 | 615 | | |
| |||
616 | 621 | | |
617 | 622 | | |
618 | 623 | | |
| 624 | + | |
619 | 625 | | |
620 | 626 | | |
621 | 627 | | |
| |||
681 | 687 | | |
682 | 688 | | |
683 | 689 | | |
| 690 | + | |
684 | 691 | | |
685 | 692 | | |
686 | 693 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
0 commit comments