Repository navigation
fix(spec)!: refuse an array under $ne at the shared comparand-shape face and at FieldOperatorsSchema.$ne - #20204
Conversation
…ace and at FieldOperatorsSchema.$ne Ruling A (record 5805254639): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator, $nin. - filter-comparand-refusal-text.ts: NIN_OPERATOR_SPELLINGS, the $ne remedy and arrayInequalityComparandMessage, sharing one template with the equality-slot sentence. - filter-comparand-shape.ts: a $ne arm in the existing walk; the $nin row reads its spellings from the shared text module. - filter.zod.ts: inequalityComparandSchema, shared by FieldOperatorsSchema.$ne and its documentation copy EqualityOperatorSchema.$ne. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…ot; register the ADR-0087 entry - filter-comparand-shape.test.ts: the $ne arm's refusal rows (envelope, path, the $nin remedy, every $ne AST spelling), its controls, the 500-char bound rows; the todo that stood for this arm is gone; the array-valued LIT CONTROL set is exactly the list operators now. - filter-ne-array-schema-door.test.ts: FieldOperatorsSchema.$ne and EqualityOperatorSchema.$ne refuse with code, path and prescription; the NormalizedFilter AST; one text, two doors; controls at both doors. - filter-equality-array-schema-door.test.ts: the $ne control row's face half is false now; re-judged into a test that keeps its guard. - service-analytics where-equality-slot-list-refusal.test.ts: the $ne pin flipped to the face's $ne sentence (pin sweep). - semantic entry filter-ne-array-comparand-refused, registry regenerated, dropped-refinements ledger gains the three $ne sites. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…slot, a minor narrowing Clause-②: no (narrowing), the BREAKING banner at minor, and the ADR-0087 registered marker for filter-ne-array-comparand-refused. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…-array-shared-face-and-schema-door
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2ca2843eda81f7c1f51c4cf6f3f38d9fb086e71a && git checkout 2ca2843eda81f7c1f51c4cf6f3f38d9fb086e71a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 90b9d496fbe909cc25f7a467caddead0ad576a6d && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 90b9d496fbe909cc25f7a467caddead0ad576a6d
node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a
|
Contract reviewServed-tier: Inputs read: card #19886 body and all 30 comments (ruling A ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…9886 now refuses #20204 refuses a list under $ne at the shared face and at FieldOperatorsSchema.$ne; the save door does not judge it yet (#20116). The docblock and one test label said no ruling decided it. Wording only. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
… promises no default symbol (objectstack-ai#20045) (objectstack-ai#20223) Fixes objectstack-ai#20045 Clause-②: yes `Clause-②: yes` is the claim's line (comment `5854472496`), copied as it stands. The changeset carries `Clause-②: yes (narrowing)`, because AGENTS.md makes a narrowing BREAKING and `check:adr-0087-registration` reads the arm there. ⚠ This diff widens nothing: it only refuses a shape that parsed before. Read against `scripts/pm/clause2-line.mjs` (「本卡放宽接受集或扩大公开面吗」), the value that fits is `no (narrowing)`, which is ruling B's own declaration on objectstack-ai#19629. The dev report raises this as an open question. Neither line was changed on the dev's own judgement. Branch `claude/issue-20045-inline-currency-column-scale`, base `1c8b320a89`, merged with `origin/main` `b09ce67870` as `4a6e8ed61a` through `scripts/pm/os-regen-merge.sh`. Every reading below was taken at head `4a6e8ed61a` unless it says otherwise. ## What changes Triage direction: comment `5825687357`, option A. Triage read the card as inherited from ruling B `5791803339` on objectstack-ai#19629 (`scale` retired from the currency field) and ruling 乙 `5805782503` on objectstack-ai#19910 (「a currency's ISO 4217 minor unit decides its display」). 1. `InlineGridColumnSchema` (`packages/spec/src/data/field.zod.ts`) now refuses an authored `scale` on a column that declares `type: 'currency'`. The refusal is one custom issue at the column's `scale`, and it applies to any value, `scale: 0` included, computed or not. The message carries ruling B's shape: - first sentence: "`scale` is not valid on a `currency` inline grid column — delete the key." This is the field refusal's first sentence with only the subject swapped; - remedy: the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the cell displays the amount and the width a computed amount is rounded to. It names no other key. - No alias and no grace window. - `scale` on a `number` column, and on a column that declares no `type`, is unchanged. 2. The `prefix` describe no longer says 「(default '¥')」. It now reads: the symbol replaces the resolved currency's own symbol; there is no default; it replaces the symbol only. `prefix` is still accepted. The `scale` describe drops "numeric/currency" and names the currency refusal. 3. ADR-0087: the D3 semantic entry `18.inline-grid-column-currency-scale-refused` is added, and `registry.ts` was regenerated with `gen:migration-registry`. 4. The changeset makes `@objectstack/spec` `minor`, with a BREAKING banner, a FROM → TO table and the `registered` disposition marker. 5. `dropped-refinements.baseline.json` gains the new refinement's rows, exactly as the build's ratchet printed them: `data/InlineGridColumn` (root) plus one `…inlineColumns.element` site on each of the 12 published schemas that embed the column. The header totals moved with the body: 208 schemas, 590 sites after the merge. 6. `content/docs/references/data/field.mdx` was regenerated (`check:generated --fix`, which proved only `check:docs` stale). ## ADR-0087 disposition: why D3 only - **A D3 entry is owed.** Ruling B on objectstack-ai#17152 (`5615360777`, restated `5634031140`) says 「D3 每家族一条,D2 可无损表达者亦然;D2 只承载数据修复」. - **No D2 conversion.** The card inherits ruling B on objectstack-ai#19629, which says no alias and no grace window. Its own entry, `field-currency-scale-refused`, is "NOT mechanically converted, deliberately". A load-path conversion that dropped the key would accept it on every load, which is the grace window the ruling refused. This entry follows that precedent. - **No `retired-keys/` row.** The key is not removed. It stays in the walked shape and stays live on `number` columns, and a `RETIRED_KEYS_BY_MAJOR` row naming a live key reds gate (b2). ## Premise, measured - On `1c8b320a89`, `InlineGridColumnSchema` was a bare `strictObject` with no refinement, and `scale` was declared for a "computed numeric/currency result". - The ablation below is the executable half of that reading. With the new refinement short-circuited, all six refusal pins go red, because every currency-plus-`scale` shape parses again. - The objectui side was read by content: - at objectui `origin/main` `25c7d58`, `GridField.tsx` has `function currencyAdornment` ×1 and `function currencyWidth` ×1, and `c.prefix || '¥'` ×0; - at this repo's `.objectui-sha` pin `f8a9d0fb0596`, it has `c.prefix || '¥'` ×2 and the `currency ? 2` default ×1 (see Acceptance notes). ## Mechanism hypothesis H1, measured - `InlineGridColumnSchema` was at `:895`, a `strictObject`, with `prefix` at `:924` and `scale` at `:938`. **Confirmed.** - "Reuse the refusal sentence or the shared helper" was **partly falsified**. No helper exists: ruling B's sentence is a string literal inside `FieldSchema`'s `superRefine` (`:2295–2307` on base). That block is outside this claim's file surface. So the column carries the sentence and remedy in its own constant, `INLINE_GRID_CURRENCY_SCALE_REFUSAL`. - A parity pin keeps the two refusals from drifting apart. It reads both refusals and asserts that the first sentences match (subject swapped), that both carry the same minor-unit clause, and that neither names `currencyConfig` or `precision`. - `FieldSchema`'s currency-precision anchor was not touched. It changed on `main` during this run (objectstack-ai#20011, PR objectstack-ai#20209). It merged cleanly and is disjoint from this diff. ## Authored instances (H2), census on `1c8b320a89` - `git grep inlineColumns` over the whole tree gives 30 files. Exactly **one** authored block: `examples/app-showcase/src/data/objects/invoice.object.ts`, with 7 identity-only `{ name }` columns, 0 declaring `type` and 0 declaring `scale`. This is the control: the same instrument counts 7 column entries in that block. - Platform objects, `skills/**`, `content/docs/**` prose and examples, and JSON/YAML fixtures: 0 inline grid columns. - Objectui at the pin: 0 authored `inlineColumns` fixtures. Its only hits are the two renderer files. - Test fixtures: 1 carried `scale: 2` on a currency column (`inline-related-columns.test.ts`, the "every renderer-read key" case). It was re-judged: `scale` was deleted there, and the key stays covered by the identity-only computed `amount` column in the same fixture. - Deployed metadata: **NOT MEASURED**, because no instrument reaches it. ## Tests, at head `4a6e8ed61a` All heavy runs went through `scripts/pm/os-verify-lock.sh`, and each verdict below is read from its VERDICT line. - `@objectstack/spec` build: exit 0. - `check:generated`: "All 15 generated artifacts are up to date". - spec `vitest --project local`: **542 files passed; 15932 passed, 2 todo**. - spec `typecheck`: exit 0 (`check:test-typecheck: OK — 53 file(s) / 255 error(s) / 142 pinned signature(s) held`). - spec `vitest --project repo`: 32 files / 586 passed, at `4dbb609b1a` before the merge. It was not re-run on the merged head, because the run took 15m23s on a box under load 30 to 49. It is declared to CI. - The new file `inline-grid-column-currency-scale-refused.test.ts` covers: - refusal through `FieldSchema`, `ObjectSchema` and the standalone column, located at `…inlineColumns[i].scale`, with `code: 'custom'`; - every value, computed or not; - controls: a `number` column, an untyped column, `prefix` accepted, and the remedy parsing and re-parsing unchanged; - field/column shape parity; - the two describes; - the D3 entry being registered. **Ablation**, from committed state `aa4b6ca14f`, run with `scripts/ablation-replace.mjs`: - The anchor `if (column.type === 'currency' && column.scale !== undefined) {` was hit ×1 and went ×1 → ×0. The replacement `if (false && …` went ×0 → ×1. The blob changed from `147220988d04` to `ddf1ebbf0437`. - Result: **6 failed | 22 passed** (28). All six refusal/parity pins went red, and the controls stayed green, which is the expected direction. - Restore: blob `147220988d04` == HEAD, and `git diff HEAD` is empty. - The subject is imported from `src` (`./field.zod`), so no dist rebuild was involved. **Gates.** `dispatch-gates.mjs --commands` was re-derived on `4a6e8ed61a` and gave 111 families, the same list as before the merge. `--ran` reconciliation: "111 derived famil(ies) accounted for — 109 run, 2 NOT-MEASURED". - `NOT MEASURED: check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET. 33 packages have no `dist/`, and only a full-repo build satisfies that. This diff changes no entry point, export map or build config. - `NOT MEASURED: check:type-check-debt`. Before the merge it exited 3 (`@objectstack/driver-turso` unbuilt). On the merged head, with that package built, `--re-measure` began a workspace-closure `turbo build` outside the verify lock. It was stopped by its recorded PID after 9 minutes, with no verdict. This diff changes no exported type: `check:api-surface` is green, and `superRefine` keeps `InlineGridColumnSchema` a `ZodObject`. It touches none of the four DEBT-ledgered packages. ## Deviations - **File surface.** `packages/spec/dropped-refinements.baseline.json` is outside the claim's listed surface. The build refuses a new refinement without it. The file is hand-edited on purpose and has no generator, and its rows were applied exactly as the gate printed them, including the merge-time header recount. The claim's surface names "its own refinement", so this is reported as that refinement's mechanical consequence, ⛔ not as scope growth. - **Merge.** `origin/main` moved the same ledger's header totals (objectstack-ai#20204), which caused a textual conflict. It was resolved by recounting the merged body. ## Acceptance notes - **Reach of the refusal.** Only a DECLARED column `type` is judged. A column that declares no `type` gets its type from the child field when objectui hydrates it (`deriveMasterDetail.ts` `hydrateColumns`, which spreads the authored column). So `{ name: 'amount', scale: 2 }` over a currency child field still parses. The column schema cannot see the child object. Once the objectui follow-up stops reading an authored `scale` on currency columns, that key is ignored on such a column. That lands on the objectui follow-up card (triage note 4), not here. - **Pinned console vs this describe.** The new `prefix` describe ("No default") is true of objectui `main` (`25c7d58`). The console bundled at `.objectui-sha` `f8a9d0fb0596` still falls back to `¥` and rounds a computed currency with no `scale` to 2. The describe becomes true of the bundled console when the pin moves past objectui PR objectstack-ai#10405. Deleting `scale` loses no decimals under either console: the pin falls back to 2, and `main` to the ISO minor unit. So no zero-decimal window opens. - **Stale notes outside this surface**, recorded only (carrier: none): - `packages/spec/liveness/field.json` → `inlineColumns.children.prefix` / `.scale` notes still cite `c.prefix || '¥'` and `c.scale ?? currency default 2`; - the `packages/spec/src/data/field-scale.ts` docblock sentence "The one `2` that looks like a currency default belongs to an inline grid COLUMN's rounding" describes the pre-objectui#10355 grid. - **The objectui `GridField` follow-up** (drop the authored-`scale` read on currency columns) is objectui's own card, per triage note 4. Nothing in objectui changes here. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…efused when it is saved (objectstack-ai#20116) (objectstack-ai#20247) Part of objectstack-ai#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 objectstack-ai#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 - **`packages/spec/src/data/filter-save-door-refusals.ts` (new, not in the `data` barrel).** It holds `reportQueryFaceRefusals`, the one function both walks call. It asks the face about one slot, `{ [field]: comparand }` or `{ [field]: { [op]: comparand } }`, and reads only an `INVALID_FILTER` throw as a verdict; anything else is rethrown. Then it picks the words (below). It applies the flag rule to `$null` / `$exists`. It is a module of its own because `data/index.ts` re-exports `filter.zod.ts` whole, so an export there would be published API. It cannot import `filter.zod.ts` without a cycle, so the enforced operator slots (`FieldOperatorsSchema`) are passed in. - **`FilterConditionSchema`'s walk (`checkFilterConditionComparands`)** asks that function about every implicit comparand and every operator of a field entry, at depth 0: this node's own field entries, and every `$and` / `$or` / `$not` member through the schema's own re-parse. That is the face's reach. The walk's hand-written equality-slot checks (objectstack-ai#19889) are now two of the face's arms, with the same sentence and the same path. - **objectstack-ai#20207's carrier walk (`dataset.zod.ts`, renamed `refuseNestedRelationEqualityLists` → `refuseNestedRelationComparands`)** asks the same function about every entry INSIDE a nested relation. That is the analytics door's reach, which flattens a relation to dotted members. The walk still decides only where; the verdict and the words are the shared function's. Its equality-list refusals keep their sentence and path. - **Zone 2 answer 2, measured:** the face's own text cannot be reused without its location words. Its builders are module-private and put ` at PATH` mid-sentence in seven different shapes (`at P.`, `(at P)`, `(at P[i])`, `at P[i] of`, …). So the face is the JUDGE and not the text source; the text is chosen per arm as listed next. The objectstack-ai#19889 / objectstack-ai#20204 precedent moved the text into a shared module first, which would edit the face (⛔ in this order). ### 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`): ```text 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 - `checkFilterConditionComparands`: a new section, "Every slot the query faces refuse is refused on save (objectstack-ai#20116)". The old bullet "⛔ `$ne` is not judged by this walk" is replaced; it was made false. - `inequalityComparandSchema`: the scope note "its own walk … does not judge `$ne`" now says the walk refuses the same shape through the face. - `refuseNestedRelationComparands`: the "⛔ Not judged here: `$ne` … and the face's OTHER arms inside a nested relation" paragraph is replaced by "Every slot the door refuses inside a relation (objectstack-ai#20116)". ## 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 objectstack-ai#20207's carrier refinement sits on the two dataset carriers only (triage record `5825670610`). The same gap holds for objectstack-ai#20080's own equality-list shape on these two carriers. Closing it means applying the same refinement to `DashboardWidgetSchema.filter` and the report `runtimeFilter`s, 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: - `filter.test.ts` "is not judged by the loose FilterConditionSchema — and neither is the objectstack-ai#7596 shape" became "is refused by FilterConditionSchema too, at the endpoint, in the operator slot's words". It asserts the path `age.$between.1` and equality with `FieldOperatorsSchema`'s message for the same pair. - objectstack-ai#20207's §4 control row "`$ne` carrying a list … not yet at this save door (objectstack-ai#20116)" became a refusal test on both carriers. It asserts `INVALID_FILTER` / 400 at the analytics door, then the carrier's message equal to the door's less its location, and the `$nin` remedy. - **Patch round:** `packages/rest/src/analytics-filter-refusal-envelope.test.ts`. "a $between with one bound → 400 INVALID_FILTER" moved from the normalizer table to the `[objectstack-ai#17551]` door table: it now asserts 400, `VALIDATION_FAILED`, exactly one `details.fields[]` entry at `selection.runtimeFilter.amount.$between`, and the face's sentence without ` at where.`. The sibling-schema control now asserts the refusal at `where.amount.$between` in the same sentence. The pass-list control is three spellings, not four. Its objectstack-ai#20010 note moved with the row and still holds: the sentence is still the face's. - **Patch round:** `packages/spec/src/data/filter-ne-array-schema-door.test.ts` §5. Its `it.todo` ("the carrier still saves the shape") is fulfilled by this PR's `$ne` arm. It is now a real pin: path `stage.$ne`, message equal to the face's less its location, the `$nin` remedy, a dataset carrier at `filter.$or.0.stage.$ne`, and a null / scalar control. **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](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #19886
Clause-②: no
Stage 2b of #19886, and only 2b: ruling A item 1 (record
5805254639), at its two named positions. The shared comparand-shape face refuses an array under$nefor every driver, andFieldOperatorsSchema.$nerefuses it at parse. Both print one remedy text, which names$nin, the list-negation operatorFieldOperatorsSchemadeclares. #19886 stays open for stage 2d (the three same-class leaks recorded in5806608550), which this PR does not touch.Clause-②: noabove is the claim's line (5853529590), copied as it stands. The changeset carriesClause-②: no (narrowing), because AGENTS.md makes a narrowing BREAKING andcheck:adr-0087-registrationreads the arm there. Both give the valueno.Dispatched by the
domain:specseat 1 PM loop, sessionsession_01Rjy9MeetSfq34PKn81CRiN, branchclaude/issue-19886-ne-array-shared-face-and-schema-door, base9e7824a4, thenorigin/main560b724cmerged in (90b9d496). Every reading below names the tree it was taken on.1. Measured first (before the change,
origin/main9e7824a4)The ruling quoted, verbatim: 「The shared comparand-shape face refuses an array under
$nefor every driver, andFieldOperatorsSchema.$ne(filter.zod.ts:269) refuses it at parse — one remedy text, naming the declared list-negation operator by its spec spelling (the dev reads it offFieldOperatorsSchema; ⛔ not invented here). ⛔ No alias, ⛔ no window.」 The line number was:269at ruling time. On9e7824a4the documentation copyEqualityOperatorSchema.$nesits at:300and the enforcedFieldOperatorsSchema.$neat:1483. Both werez.any().Stages 2a (PR #19946) and 2c (PR #19947) had already closed the two faces that ANSWERED the shape: the formula evaluator and
driver-mongodb's walk. What still accepted an array under$ne, read on9e7824a4with atsxprobe againstsrc/:{ tags: { $ne: ['a'] } }(and[], and under$or/$not)assertListComparandShapes(the shared face)parseFilterAST([['tags', 'ne', ['a']]]){"tags":{"$ne":["a"]}}and passedFieldOperatorsSchema/EqualityOperatorSchema,{ $ne: ['a'] }and{ $ne: [] }success: trueNormalizedFilterSchema,{ $and: [{ s: { $ne: ['a'] } }] }success: trueFilterConditionSchema/DatasetSchemafilter: { stage: { $ne: [...] } }success: true(not a named position; see section 7)ViewFilterRuleSchema,not_equalswith['a']$eq: ['a'](the landed equality arm)INVALID_FILTER/ 400$ne: 'a',$ne: null,$ne: { $field: 'budget' }objectql's engine binds the face through its delegating wrapper (packages/objectql/src/filter-comparand-shape.ts), so it passed too.The analytics
wheredoor runs the face, so onmainit compiled the shape instead of refusing it.normalizeAnalyticsFilterTree({ where: { stage: { $ne: ['won', 'lost'] } } })returnedstagenot-set ORstagenot-equals["won","lost"]. Both analytics strategies render a not-equals member from its first value (values[0]in the native SQL strategy,v0in the ObjectQL strategy; read at source, not executed). So'lost'was dropped in silence, and a chart counted rows its filter named. This tree was measured with the face's$nearm removed (the ablation in section 4), which ismain's face. The analytics door's own code is unchanged by this PR.2. What changed
Three source files in
packages/spec/src/data/. Nothing in any driver, in formula, or in objectql.filter-comparand-refusal-text.ts: the module both doors import, which already carried the equality-slot sentence. It gainsNIN_OPERATOR_SPELLINGS, the$neremedyARRAY_INEQUALITY_COMPARAND_REMEDY, andarrayInequalityComparandMessage. The$eqand$nesentences now share one private template, and the equality sentence is byte-identical, as its existing pins prove.filter-comparand-shape.ts: a$nearm in the existing walk (⛔ no second walk), beside the$eqarm, with its own error builder. The$ninrow ofLIST_COMPARAND_OPERATORSnow reads its spellings from the shared module, as the$inrow already did. The module docblock gains the ruling's section, and the equality section's bullet that said$newas not judged is corrected.filter.zod.ts:inequalityComparandSchema(), one factory shared byFieldOperatorsSchema.$neand its documentation copyEqualityOperatorSchema.$ne. It mirrorsequalityComparandSchema()exactly:z.any()except an array, and the describeNE_DESCRIPTIONis unchanged.The face's refusal, verbatim:
The operator slot's issue (code
custom, path$ne), verbatim:The first sentence is
driver-memory'sarrayComparandErrorfor$ne, word for word. The remedy is ONE operator, as the ruling says:$nin, read offFieldOperatorsSchema("Not in list"), with the authoring spellings that lower to it.$notContainsis not offered, because it is declared on a STRING comparand and is not a list operator. The equality slot's$in/$containsare not offered either, because they answer a different question.$ninis also the remedy the formula anddriver-mongodbfaces already name for this shape.Also in the diff: one ADR-0087 semantic entry (
18.filter-ne-array-comparand-refused.ts) and the regeneratedregistry.ts. Thedropped-refinements.baseline.jsonledger gains the three$nesites the build named (data/EqualityOperator$ne,data/FieldOperators$ne,data/NormalizedFilterlazy.$not.options[0].valueType.$ne; sites 574 to 577). The changeset is@objectstack/specminor. It carries the BREAKING banner,Clause-②: no (narrowing), and the ADR-0087registeredmarker forfilter-ne-array-comparand-refused.check:generatedreads all 15 artifacts current on90b9d496.spec-changes.jsonand the upgrade guide do not list major-18 entries (the sibling equality entries are absent too), so they did not move.3. Pins
Every accept/refuse change is pinned, and every refusal case asserts its envelope.
packages/spec/src/data/filter-comparand-shape.test.ts(the face). Theit.todothat stood for this arm is replaced by real pins:ne,not_equalsand!=; the object passthrough;[]; nested under$and(lowered),$orand$not; and beside a legal$eq. Each assertscodeINVALID_FILTER,status400, the path, and the$neleading sentence.$ninremedy, including that$in,$containsand$notContainsare absent. The context prefix is kept.$nerefuses an array. The spellings are derived with a scalar probe, and a guard pins exactly six:!=, the angle-bracket pair,ne,neq,not_equals,notequals.$ninis a key ofFieldOperatorsSchema, and the spellings the message lists are exactly those that lower to it.$ne: null(both spellings), scalars,0,false,'', aDate, and a{ $field }reference.$-key boundary.LIT CONTROLset is now exactly$between,$in,$nin.packages/spec/src/data/filter-ne-array-schema-door.test.ts(new; the operator slot and the one-text rule):$ne: [...]and$ne: [], asserting the issue codecustom, the path$neand the full prescription. A$nearray beside a legal$eqraises one issue, at$ne.NormalizedFilterSchemarefuses at$and.0.stage.$ne, with a scalar control.on field "stage"andat where.stage.$ne(both proved present first). This is checked over four lists. Every FilterArray spelling gives the face's sentence, and the remedy is declared and single.it.todofor the stored-carrier walk (section 7), ⛔ deliberately not pinned green.4. Proof the pins can fail (ablation, two legs, each position alone)
Both legs ran from committed state (
3757d64a), withscripts/ablation-replace.mjs(anchor must hit; blob hash asserted), atraprestore onEXIT INT TERMagainst an absolute path, and restore proven bygit diff HEAD= 0 bytes.throwguarded by an impossible field name): on disk marker 1 and anchor 0. Spec rebuilt, andablation-dist-preflightfound the marker in 6 dist files. Results:$nepin, the §2 / §3 two-door parity, and the equality door's re-judged$netest went red. The slot's §1 pins stayed green, which is correct, because only the face was cut.service-analytics: 1 failed (the flipped pin), and its parity file stayed green by design.--absentread the marker absent from all 222 dist files, the tree was clean, and both suites were green again (167 passed / 2 todo; 160 passed).addIssueguarded by an impossible length; spec tests readsrc/): on disk marker 1 and anchor 0. Spec: 12 failed / 155 passed. Every slot pin in §1 and §3 went red, plus theLIT CONTROLset, which read$neas array-valued again. The face pins stayed green. Restore: 0 diff bytes, and the tree was clean.The two red sets are disjoint where they should be, so each pin is tied to the position it names.
5. Pin sweep and consumer suites
Pin sweep. Error code and message were grepped repo-wide:
$nefollowed by an array literal, the FilterArrayne-family triples carrying an array, andnot_equalsview rules. Pins the new refusal made FALSE, flipped in one round, each to assert the new substance:filter-comparand-shape.test.ts: theit.todoand theLIT CONTROLset.filter-equality-array-schema-door.test.ts§4, the row$ne carrying an array — not this ruling. It asserted that the face PASSES the shape, and that half is false now. It is re-judged into its own test, which asserts the face's$nerefusal (envelope, the$ninremedy, not the$inone). It keeps the row's real guard: the carrier walk's EQUALITY arm raises nothing for$ne. That is asserted on the equality sentences, not onsuccess, so no ruling-less acceptance is pinned green.service-analyticswhere-equality-slot-list-refusal.test.ts:.not.toThrow(/requires a single comparable value/)was false. It now asserts the envelope, byte equality with the face, the$nesentence and the$ninremedy, and that the words are not this door's equality-slot sentences.Pins that move by construction and were read, not edited:
objectqlengine-aggregate-having-comparand-shape.test.ts(the$ne: [500]row, through the engine's wrapper) is written as parity with the face. It now takes its refusal branch, asserting the envelope and the face's message on both doors.service-analyticswhere-face-arms-refusal.test.ts(the parity block) now compares two refusals.driver-memory's AST-vocabulary probe helper now readsundefinedfor thenespellings and hands them the same scalar it always did.Consumer suites.
Every suite below ran through
scripts/pm/os-verify-lock.sh, with its exit code captured before any pipe. Two heads:3757d64a(before the merge ofmain)90b9d496(after it)@objectstack/spec, the wholelocalproject (3 shards)@objectstack/service-analytics@objectstack/objectql, the 29 files touching the face,parseFilterAST, comparands or$ne@objectstack/formula@objectstack/lint@objectstack/driver-memory@objectstack/driver-mongodbmongod)@objectstack/driver-sql, 44 filter / comparand files@objectstack/plugin-security, the RLS / write-check filesThe post-merge re-run is a declared narrowing. It covers the packages the merged commits touched (
spec,formula,driver-mongodb,plugin-security) and my two analytics pin files. The rest were green on3757d64a, and the merge changed neither them nor this diff.Typecheck on
3757d64a:pnpm --filter @objectstack/spec run typecheckexited 0; its test layer heldtest-typecheck-debt.jsonunchanged.pnpm --filter @objectstack/service-analytics run typecheckexited 0, andtsc --listFilesconfirms the edited test file is in that program. On the merged head90b9d496the spec typecheck is NOT MEASURED at the time this PR opened. Two lock acquisitions returned 99 across about 20 minutes, behind a single holder of more than 17 minutes. The merged-in commits touch no file this diff imports, and CI's requiredTypeScript Type Checklane measures this head. The report comment on #19886 carries the reading if it lands before the report.eslint (
--no-inline-config --format json) on the 9 touched.tsfiles at90b9d496: 9 files linted, 0 errors, 0 warnings. The population is read fromeslint.config.mjs: every file sits in its**/*.{ts,…}andpackages/**/*.{ts,…}objects. The config never enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.6. Census (Zone 2 item 5): no producer
9e7824a4.$nefollowed by an array literal inpackages/**,examples/**,apps/**,scripts/**,content/**andskills/**: 20 hits, all tests, refusal code, comments or migration prose. Control:$infollowed by an array inpackages/**andexamples/**has 901 hits. The FilterArrayne-family with an array has 0 hits. A CEL!=against a list literal in platform objects, examples andpackages/qa/**(non-test) has 0 hits.$nefed by a variable in non-test source has 11 sites, and none builds a list..objectui-shapinf8a9d0fb05: 2 hits, both its own refusal test. Its dataset builder'snotEqualsis scalar-arity (the list-arity set isin,not_in), and the summary editor lists onlyin/notIn. The control has 46 hits.main48d7066: 0 hits. The control has 33 hits.A real producer would have changed what ADR-0087 owes. None exists, so the entry carries no D2 conversion.
7. What this does NOT do (acceptance notes)
FilterConditionSchema, which every stored carrier (dataset, widget, report, rollup, and the rest) parses through, does not route a field's operator map throughFieldOperatorsSchema. Its walk judges$eqand not$ne. SoDatasetSchemawithfilter: { stage: { $ne: ['won', 'lost'] } }still parses green on this head, and every query that uses it is refused at the face. Ruling A names the face and the operator slot, not that walk, and [finding]FilterConditionSchemastill PARSES{ field: [...] }and{ field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889's ruling had to nameFilterConditionSchemaexplicitly for the equality slot. Extending the walk narrows every carrier's accept set, so it is ⛔ not taken here. It is raised in the report for the seat, and pinned only as anit.todo. This is not a new split: onmainevery backend already refused the shape at query time.$in, a{ $field }referent to a multi-valued field) is untouched, and so are all driver and formula sources.driver-memory'sarrayComparandErrortext says the spec's comparand door "leaves this position to the driver". That has been untrue for the equality slot since [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757, and is now untrue for$ne. It is reachable only by a caller that hands a raw filter to the driver.filter-equality-array-comparand-refused-at-savedescribes$neas a position "which no ruling has decided".19889-filter-schema-door-array-equality.mdlists "$necarrying an array is not judged" among what that change did not do.{}at$ne, which is declared in the dropped-refinements ledger.8. Gates
Derived on the actual change set (
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, merge base560b724c, head90b9d496): 89 commands. That is the dispatch lead's 77 plus 12 this diff adds (the changeset families,check:where-matcher,check:engine-double-contract,check:objectql-double-limit,check:type-check-coverage,check:type-check-debt, and others). None of the lead's commands dropped out. Every line was run with its exit code written to disk, then reconciled with--ranusingcommand :: exit Nrecords: 89 derived, 87 run with exit 0, 2 NOT MEASURED, 0 unrun.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt. Both exited 3,PREREQUISITE NOT MET: each reads every workspace package's builtdist/, and this worktree built only the closures of the suites above. They are whole-tree families that CI runs after its full build. This diff changes no package's exports or build shape.check:adr-0087-registration --base origin/main(it reads the changeset's(narrowing)arm and theregisteredmarker),check:changeset-no-major, thecheck:changeset-gate-self-tests,check:nul-bytes,check:doc-authoring,check:where-matcher, and the spec familiescheck:api-surface,check:authorable-surface,check:docs,check:spec-changes,check:migration-registry,check:upgrade-guide,check:livenessandcheck:exported-any.Local runs are not a complete account of CI: the artifact-roster families, the wide-population families, the path-scheduled jobs and the type-check lanes are CI's.
Generated by Claude Code