Skip to content

fix(spec)!: refuse an array under $ne at the shared comparand-shape face and at FieldOperatorsSchema.$ne - #20204

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-ne-array-shared-face-and-schema-door
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-ne-array-shared-face-and-schema-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse. Both print one remedy text, which names $nin, the list-negation operator FieldOperatorsSchema declares. #19886 stays open for stage 2d (the three same-class leaks recorded in 5806608550), which this PR does not touch.

Clause-②: no above is the claim's line (5853529590), 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 give the value no.

Dispatched by the domain:spec seat 1 PM loop, session session_01Rjy9MeetSfq34PKn81CRiN, branch claude/issue-19886-ne-array-shared-face-and-schema-door, base 9e7824a4, then origin/main 560b724c merged in (90b9d496). Every reading below names the tree it was taken on.

1. Measured first (before the change, origin/main 9e7824a4)

The ruling quoted, verbatim: 「The shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$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 off FieldOperatorsSchema; ⛔ not invented here). ⛔ No alias, ⛔ no window.」 The line number was :269 at ruling time. On 9e7824a4 the documentation copy EqualityOperatorSchema.$ne sits at :300 and the enforced FieldOperatorsSchema.$ne at :1483. Both were z.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 on 9e7824a4 with a tsx probe against src/:

door { tags: { $ne: ['a'] } } (and [], and under $or / $not)
assertListComparandShapes (the shared face) PASS, at every depth
parseFilterAST([['tags', 'ne', ['a']]]) lowered to {"tags":{"$ne":["a"]}} and passed
FieldOperatorsSchema / EqualityOperatorSchema, { $ne: ['a'] } and { $ne: [] } success: true
NormalizedFilterSchema, { $and: [{ s: { $ne: ['a'] } }] } success: true
FilterConditionSchema / DatasetSchema filter: { stage: { $ne: [...] } } success: true (not a named position; see section 7)
ViewFilterRuleSchema, not_equals with ['a'] already refused at authoring
control: the face on $eq: ['a'] (the landed equality arm) refused, INVALID_FILTER / 400
controls: $ne: 'a', $ne: null, $ne: { $field: 'budget' } pass at every door

objectql's engine binds the face through its delegating wrapper (packages/objectql/src/filter-comparand-shape.ts), so it passed too.

The analytics where door runs the face, so on main it compiled the shape instead of refusing it. normalizeAnalyticsFilterTree({ where: { stage: { $ne: ['won', 'lost'] } } }) returned stage not-set OR stage not-equals ["won","lost"]. Both analytics strategies render a not-equals member from its first value (values[0] in the native SQL strategy, v0 in 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 $ne arm removed (the ablation in section 4), which is main'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 gains NIN_OPERATOR_SPELLINGS, the $ne remedy ARRAY_INEQUALITY_COMPARAND_REMEDY, and arrayInequalityComparandMessage. The $eq and $ne sentences now share one private template, and the equality sentence is byte-identical, as its existing pins prove.
  • filter-comparand-shape.ts: a $ne arm in the existing walk (⛔ no second walk), beside the $eq arm, with its own error builder. The $nin row of LIST_COMPARAND_OPERATORS now reads its spellings from the shared module, as the $in row already did. The module docblock gains the ruling's section, and the equality section's bullet that said $ne was not judged is corrected.
  • filter.zod.ts: inequalityComparandSchema(), one factory shared by FieldOperatorsSchema.$ne and its documentation copy EqualityOperatorSchema.$ne. It mirrors equalityComparandSchema() exactly: z.any() except an array, and the describe NE_DESCRIPTION is unchanged.

The face's refusal, verbatim:

Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. 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.

The operator slot's issue (code custom, path $ne), verbatim:

Operator "$ne" 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.

The first sentence is driver-memory's arrayComparandError for $ne, word for word. The remedy is ONE operator, as the ruling says: $nin, read off FieldOperatorsSchema ("Not in list"), with the authoring spellings that lower to it. $notContains is not offered, because it is declared on a STRING comparand and is not a list operator. The equality slot's $in / $contains are not offered either, because they answer a different question. $nin is also the remedy the formula and driver-mongodb faces already name for this shape.

Also in the diff: one ADR-0087 semantic entry (18.filter-ne-array-comparand-refused.ts) and the regenerated registry.ts. The dropped-refinements.baseline.json ledger gains the three $ne sites the build named (data/EqualityOperator $ne, data/FieldOperators $ne, data/NormalizedFilter lazy.$not.options[0].valueType.$ne; sites 574 to 577). The changeset is @objectstack/spec minor. It carries the BREAKING banner, Clause-②: no (narrowing), and the ADR-0087 registered marker for filter-ne-array-comparand-refused. check:generated reads all 15 artifacts current on 90b9d496. spec-changes.json and 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). The it.todo that stood for this arm is replaced by real pins:
    • 9 refusal rows: the lowered ne, not_equals and !=; the object passthrough; []; nested under $and (lowered), $or and $not; and beside a legal $eq. Each asserts code INVALID_FILTER, status 400, the path, and the $ne leading sentence.
    • The full sentence and the single $nin remedy, including that $in, $contains and $notContains are absent. The context prefix is kept.
    • Every AST spelling that lowers to $ne refuses 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.
    • $nin is a key of FieldOperatorsSchema, and the spellings the message lists are exactly those that lower to it.
    • Controls: $ne: null (both spellings), scalars, 0, false, '', a Date, and a { $field } reference.
    • The no-$-key boundary.
    • Three rows in the 500-char client-bound test.
    • The array-valued LIT CONTROL set 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):
    • §1: both copies refuse $ne: [...] and $ne: [], asserting the issue code custom, the path $ne and the full prescription. A $ne array beside a legal $eq raises one issue, at $ne. NormalizedFilterSchema refuses at $and.0.stage.$ne, with a scalar control.
    • §2: the face's envelope at four positions.
    • §3: the slot's message equals the face's, character for character, after removing only on field "stage" and at 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.
    • §4: controls at BOTH doors, accepted and KEPT.
    • §5: one it.todo for 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), with scripts/ablation-replace.mjs (anchor must hit; blob hash asserted), a trap restore on EXIT INT TERM against an absolute path, and restore proven by git diff HEAD = 0 bytes.

  • M1, the face arm cut (throw guarded by an impossible field name): on disk marker 1 and anchor 0. Spec rebuilt, and ablation-dist-preflight found the marker in 6 dist files. Results:
    • Spec: 24 failed / 143 passed. Every face $ne pin, the §2 / §3 two-door parity, and the equality door's re-judged $ne test 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.
    • The probe printed the pre-fix analytics tree quoted in section 1.
    • Restore: 0 diff bytes. Rebuilt, preflight --absent read the marker absent from all 222 dist files, the tree was clean, and both suites were green again (167 passed / 2 todo; 160 passed).
  • M2, the operator slot cut (the addIssue guarded by an impossible length; spec tests read src/): on disk marker 1 and anchor 0. Spec: 12 failed / 155 passed. Every slot pin in §1 and §3 went red, plus the LIT CONTROL set, which read $ne as 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: $ne followed by an array literal, the FilterArray ne-family triples carrying an array, and not_equals view rules. Pins the new refusal made FALSE, flipped in one round, each to assert the new substance:

  • filter-comparand-shape.test.ts: the it.todo and the LIT CONTROL set.
  • 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 $ne refusal (envelope, the $nin remedy, not the $in one). 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 on success, so no ruling-less acceptance is pinned green.
  • service-analytics where-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 $ne sentence and the $nin remedy, and that the words are not this door's equality-slot sentences.

Pins that move by construction and were read, not edited:

  • objectql engine-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-analytics where-face-arms-refusal.test.ts (the parity block) now compares two refusals.
  • driver-memory's AST-vocabulary probe helper now reads undefined for the ne spellings 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:

suite 3757d64a (before the merge of main) 90b9d496 (after it)
@objectstack/spec, the whole local project (3 shards) 541 files passed, 0 failed (15874 tests, 2 todo) 542 files passed, 0 failed (15935 tests, 2 todo)
@objectstack/service-analytics full: 128 files, 3017 tests passed the 2 pin files: 160 passed
@objectstack/objectql, the 29 files touching the face, parseFilterAST, comparands or $ne 1053 passed not re-run (untouched by the merge)
@objectstack/formula full: 36 files, 1002 passed full: 37 files, 1027 passed
@objectstack/lint full: 108 files, 4156 passed not re-run
@objectstack/driver-memory full: 53 files, 1269 passed not re-run
@objectstack/driver-mongodb full: 27 passed, 5 skipped (live mongod) full: 28 passed, 5 skipped
@objectstack/driver-sql, 44 filter / comparand files 42 passed, 2 skipped (live PG / MySQL) not re-run
@objectstack/plugin-security, the RLS / write-check files 33 files, 1123 passed 34 files, 1138 passed

The 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 on 3757d64a, and the merge changed neither them nor this diff.

Typecheck on 3757d64a: pnpm --filter @objectstack/spec run typecheck exited 0; its test layer held test-typecheck-debt.json unchanged. pnpm --filter @objectstack/service-analytics run typecheck exited 0, and tsc --listFiles confirms the edited test file is in that program. On the merged head 90b9d496 the 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 required TypeScript Type Check lane 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 .ts files at 90b9d496: 9 files linted, 0 errors, 0 warnings. The population is read from eslint.config.mjs: every file sits in its **/*.{ts,…} and packages/**/*.{ts,…} objects. The config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.

6. Census (Zone 2 item 5): no producer

  • This repository, 9e7824a4. $ne followed by an array literal in packages/**, examples/**, apps/**, scripts/**, content/** and skills/**: 20 hits, all tests, refusal code, comments or migration prose. Control: $in followed by an array in packages/** and examples/** has 901 hits. The FilterArray ne-family with an array has 0 hits. A CEL != against a list literal in platform objects, examples and packages/qa/** (non-test) has 0 hits. $ne fed by a variable in non-test source has 11 sites, and none builds a list.
  • objectui at the .objectui-sha pin f8a9d0fb05: 2 hits, both its own refusal test. Its dataset builder's notEquals is scalar-arity (the list-arity set is in, not_in), and the summary editor lists only in / notIn. The control has 46 hits.
  • cloud main 48d7066: 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)

  • The stored-filter carrier walk. FilterConditionSchema, which every stored carrier (dataset, widget, report, rollup, and the rest) parses through, does not route a field's operator map through FieldOperatorsSchema. Its walk judges $eq and not $ne. So DatasetSchema with filter: { 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] FilterConditionSchema still 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 name FilterConditionSchema explicitly 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 an it.todo. This is not a new split: on main every backend already refused the shape at query time.
  • Stage 2d (the ordering operators with an array, a nested array inside $in, a { $field } referent to a multi-valued field) is untouched, and so are all driver and formula sources.
  • Sentences elsewhere that this change makes stale, noted and ⛔ not edited here because they are outside this claim's file surface:
  • The published JSON Schema still reads {} 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 base 560b724c, head 90b9d496): 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 --ran using command :: exit N records: 89 derived, 87 run with exit 0, 2 NOT MEASURED, 0 unrun.

  • ⊘ NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both exited 3, PREREQUISITE NOT MET: each reads every workspace package's built dist/, 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.
  • Among the 87: check:adr-0087-registration --base origin/main (it reads the changeset's (narrowing) arm and the registered marker), check:changeset-no-major, the check:changeset-gate-self-tests, check:nul-bytes, check:doc-authoring, check:where-matcher, and the spec families check:api-surface, check:authorable-surface, check:docs, check:spec-changes, check:migration-registry, check:upgrade-guide, check:liveness and check: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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 16 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.

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

  • content/docs/getting-started/common-patterns.mdx (via not_equals (literal, a string literal in semantic; a string literal in surface))
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 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2ca2843eda81f7c1f51c4cf6f3f38d9fb086e71a — the merge of head 90b9d496fbe909cc25f7a467caddead0ad576a6d into base 1c8b320a894b31dcfc76c33c8e7a62b8986d893a, 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 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

⚠️ 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 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → 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: CONTRACT_REVIEW_TIER
Head-sha: 90b9d496fbe909cc25f7a467caddead0ad576a6d

Inputs read: card #19886 body and all 30 comments (ruling A 5805254639, stage-2d note 5806608550, seat ACCEPT 5854558630, dev report 5854540936); the #19889 ruling 5805248669; PR #20204 body, files and the head's 39 check-runs; the repo at origin/main 1c8b320a and at the head. Probe: one tsx script (review/probe.mts) run on two detached worktrees under scratch, head 90b9d496 and merge-base 560b724c (outputs probe-head-unlocked.out, probe-base-unlocked.out); it is a single sub-second process over spec src/, so it ran outside the heavy-verify lock (a lock-queued copy was retired unread behind an 11-minute holder). node_modules were symlinked read-only from the installed objectstack-issue-19543 tree (zod 4.6.1 there vs the ledger's measured 4.4.3; a behaviour probe only). Worktrees removed afterwards; the main checkout was never edited.

① Derived judgments

  • The face refuses exactly an array under $ne, and nothing else — RIGHT. The arm is filter-comparand-shape.ts:864-869 (if (op === '$ne') { if (Array.isArray(spec[op])) throw …; continue; }). Before the PR $ne sat in neither ORDERING_COMPARAND_OPERATORS (:407-412) nor LIST_COMPARAND_OPERATORS (:387-394), so the walk fell through and passed it; the new continue skips nothing that previously judged $ne. Probe, merge-base: {tags:{$ne:["a"]}}, $ne:[], under $or, $not, $and, beside a legal $eq — all PASS. Probe, head: all six REFUSED INVALID_FILTER/400, sentence Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. / where.$or[1].tags.$ne / where.$not.tags.$ne / where.$and[1].tags.$ne. Depth is the existing walk (:809-821).
  • Nothing else newly refused — RIGHT. Probe on head, all PASS at the face: $ne:null, "a", 0, false, "", a Date, { $field: 'budget' }, $nin:["a","b"], $nin:[], $in:["a"], $between:[1,9], the nested relation {author:{tags:{$ne:["a"]}}} (not descended, :845-847), and the stage-2d shapes $gt:["a"] and $in:[["a"]]. $ne: null is untouched because the arm tests Array.isArray only. NE_DESCRIPTION (filter.zod.ts:260-264) is unchanged and its { $field } clause still holds.
  • Every FilterArray spelling reaches the face — RIGHT. AST_OPERATOR_MAP lowers exactly six spellings to $ne: !=, the angle-bracket pair, ne, neq, not_equals, notequals (filter.zod.ts:2281-2286); parseFilterAST lowers first and then calls the face (:2609-2611). Probe: the derived set is those six; each with ["a"] is REFUSED on head with the same sentence and PASSED (lowered to {"tags":{"$ne":["a"]}}) on the merge-base; ["tags","ne",null] and ["amount","!=",{$field}] pass on both.
  • One factory backs both slots and the AST follows — RIGHT. inequalityComparandSchema (filter.zod.ts:1017-1021, z.any().superRefine refusing Array.isArray, .describe(NE_DESCRIPTION)) is bound at EqualityOperatorSchema.$ne (:335) and FieldOperatorsSchema.$ne (:1519); NormalizedFilterSchema validates field members through z.record(z.string(), FieldOperatorsSchema) (:2141). Probe, head: both slots REFUSE $ne:["won","lost"] and $ne:[] with issue custom at path $ne; {$eq:"won",$ne:["lost"]} raises the one issue at $ne; the AST refuses at $and.0.stage.$ne and $not.stage.$ne, and {$and:[{stage:{$ne:"won"}}]} passes. Merge-base: all of these PASS. Seven scalar/$field controls pass at both slots on head. The code-only diff of filter.zod.ts between merge-base and head is the import, the factory and the two slot swaps; nothing else. EqualityOperatorSchema has no code consumer outside its export and the generated surfaces (grep at head), so "documentation copy" is accurate.
  • Envelope — RIGHT. Face: invalidFilterComparandError sets code = 'INVALID_FILTER', status = 400 (filter-comparand-shape.ts:478-484), probe confirms; the context prefix is kept (find('deal'): Operator "$ne" …). Slot: issue code custom, path $ne (probe).
  • Remedy text — TRUTHFUL. {"$nin": […]} (authoring: nin, not_in, notin): $nin is a declared key (filter.zod.ts:1541), and the AST table maps exactly nin, not_in, notin to $nin (:2312-2314; probe derives the same three). $notContains is z.string() (:1560), so excluding it is correct (probe: $notContains:["won"] refused invalid_type before and after). Formula (matches-filter.ts:178) and driver-mongodb (mongodb-filter.ts:429-430) already prescribe $nin for this shape, as the PR says. The leading sentence equals driver-memory's arrayComparandError (driver-memory/src/filter-refusal.ts:730-734) through at path. Probe: slot text equals face text minus on field "stage" and at where.stage.$ne (both present exactly once), byte for byte; face length 303, under the 500-char bound.
  • $nin face row spelling list — ACCURATE. LIST_COMPARAND_OPERATORS row ['$nin', NIN_OPERATOR_SPELLINGS] (filter-comparand-shape.ts:392), NIN_OPERATOR_SPELLINGS = ['nin', 'not_in', 'notin'] (filter-comparand-refusal-text.ts:177), identical to the old literal and to the AST table.
  • Downstream doors — the judgment is right everywhere the face runs; the PR under-states WHICH doors. Non-test callers of the face at head: objectql/src/engine.ts:974 (where, stated as "the engine lowering seam"), :16136 (aggregations[i].filter, NOT stated), :16244 (having, NOT stated), service-analytics/src/strategies/filter-normalizer.ts:1914 (the analytics where door, stated), service-analytics/src/read-scope-sql.ts:892 inside assertReadScopeComparandsRunnable (called from :687 in the native read-scope compiler and from objectql-strategy.ts:669 and :1165; NOT stated), and filter.zod.ts:2611. Every one moves PASS to REFUSED. The read-scope path matters most on a security card: compileOperator still compiles $ne as a SQL not-equals against a ? placeholder with the comparand pushed by bind (read-scope-sql.ts:1764, :1137-1140) and assertNoListInEqualitySlot explicitly defers $ne to this card (:1738-1740), so before the PR a $ne-array scope was bound as a parameter and after it is refused fail-closed as READ_SCOPE_COMPILE_FAILED / 500 (the module's declared envelope, :559). Reachability is narrow — the CEL lowering refuses a list under != since stage 2c, so only a hand-built scope carries the shape — and the direction is refuse, so this is a ③ flag, not a wrong judgment. The analytics where reading is right: objectql-strategy.ts:1816,1843 (notEquals renders { $ne: v0 }, v0 = values[0]) and native-sql-strategy.ts:1234 (values[0]) drop every member after the first. plugin-security's own code never calls the face or parseFilterAST; its write check runs formula's matchesFilterCondition (security-plugin.ts:3167, :3438, explain-engine.ts:872), closed at stage 2a, so it is unchanged here. Drivers, formula and objectql sources are untouched (diffstat).
  • The carrier FilterConditionSchema is unchanged and outside ruling A's letter — CONFIRMED. FilterConditionSchema is z.record(z.string(), z.unknown()).and(…) (filter.zod.ts:1923-1929) and never parses an operator map through FieldOperatorsSchema; checkFilterConditionComparands (:1732 ff.) judges $eq only and its code is byte-unchanged (only a docblock moved). Probe: FilterConditionSchema.safeParse({stage:{$ne:["won","lost"]}}) succeeds on both trees, while {stage:{$eq:["won"]}} and {stage:["won"]} are refused on both (the [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889 arm). Ruling A item 1 names "the shared comparand-shape face" and "FieldOperatorsSchema.$ne"; the [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889 ruling had to name FilterConditionSchema explicitly for $eq. The seat's separate-remainder recording (5854558630) is right, and the dev's it.todo in filter-ne-array-schema-door.test.ts §5 correctly pins nothing green.
  • Security statement: no path ACCEPTS anything it refused before. The face arm is purely additive; the slot factory is z.any() minus arrays, exactly equalityComparandSchema's shape; the carrier walk, drivers, formula and plugin-security code are untouched. The before/after probe shows only PASS-to-REFUSED transitions and no REFUSED-to-PASS: the landed $eq:["a"], implicit ["a"], $notContains:["won"] and $gt:["a"] at the slot answer the same on both trees.

② Semver level

  • minor with the BREAKING banner, Clause-②: no (narrowing), registered filter-ne-array-comparand-refused — RIGHT. The changeset (.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md:2,11,13,75,77) has the same form as the 2a face precedent 75367215 (.changeset/19757-equality-slot-array-refused.md:2,7,61,63), the schema-door precedent 6aa3188a, and the 2a/2c stage changesets of this card. AGENTS.md:1075: "(narrowing) is BREAKING"; check-changeset-no-major.mjs:1502 "a BREAKING change; during the launch window it ships minor" and :1646 (a no value takes the narrowing arm or none); check-adr-0087-registration.mjs:1931-1935 parses the registered disposition followed by its migration id, and :3871-3885 requires the id to exist and be NEW in the diff — filter-ne-array-comparand-refused is new (registry.ts entry 151). Both "Check Changeset" runs at the head are success. The body's Clause-②: no beside the changeset's no (narrowing) is the 6aa3188a precedent; the gate reads the changeset, and the value agrees. FROM to TO rows match the AST table (not_equals family to not_in / nin / notin).
  • ADR-0087 entry — ACCURATE. registry.ts reproduces the entry field for field (surface 528, replacement 334, reason 2479, acceptanceCriteria 892 chars, all EQUAL) and sits alphabetically between filter-icontains-comparand-refused-at-parse and filter-preset-ordering-comparand-refused. The measured split in reason matches the card body's table. No D2 is the ADR's own rule: ADR-0087 docs/adr/0087-metadata-protocol-upgrade-contract.md:152 "Semantic changes are excluded. A change whose old shape has no lossless mapping cannot be converted"; a $ne list has no single honest value. One wording note: the surface opens with data.FilterCondition and the $ne slot of data.FieldOperators while the reason says FilterConditionSchema is not moved; the type name is the runtime shape the face judges, and the reason removes the ambiguity, so this is a nit.
  • Census — HOLDS. Repo at head: 58 $ne-followed-by-array hits, every non-test non-doc one a comment (mongodb-filter.ts:343, cel-to-filter.ts:434,440, matches-filter.ts:148, filter-comparand-shape.ts:312,327); FilterArray ne-family with an array outside tests: 0 producers; control $in with an array: 1064 (the PR's 20 / 901 were read on 9e7824a4 before its own pins and prose). objectui at the pin f8a9d0fb05: 2 hits, both ValueDataSource.arrayComparand.test.ts:106,109, which pin objectui's own text ("operator '$ne'", "is an ARRAY"), not spec's; control 46, as stated. The pin does have two live parseFilterAST callers (packages/core/src/utils/drill-down.ts:254, packages/data-objectstack/src/index.ts:718), so the zero-producer count is what keeps it unaffected. cloud origin/main = 48d7066: 0 hits, control 34 (PR says 33; a regex-width difference on a control).
  • Dropped-refinements ledger +3 — RIGHT. 574 to 577 with data/EqualityOperator.$ne, data/FieldOperators.$ne, data/NormalizedFilter.lazy.$not.options[0].valueType.$ne: exactly the three positions the factory reaches (two slots plus the AST's $not option). The JSON-Schema artifacts are build outputs (no FieldOperators.json is committed); Build Core and Spec property liveness are success at the head.
  • CI at the head, final: 39 check-runs, 33 success, 6 skipped, 0 failing (all six Test Core shards, all four Type Check lanes, Dogfood, Temporal Conformance, Governed Surface Queue Guard included). git merge-tree of the head against origin/main 1c8b320a is clean (84aebee6).

③ Boundary flags

  • Acceptance note 1, driver-memory text — CORRECT as flagged. driver-memory/src/filter-refusal.ts:739 still says the spec door "leaves this position to the driver"; false for $eq 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 — and driver-mongodb alone answers it, as an exact-array match #19757 and for $ne now; reachable only by a raw driver call, and the driver is untouched.
  • Acceptance note 2, stale unreleased [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889 texts — CORRECT but INCOMPLETE. Confirmed: 18.filter-equality-array-comparand-refused-at-save.ts:59-60 ("$ne carrying a list, which no ruling has decided") and .changeset/19889-filter-schema-door-array-equality.md:26. Not listed by the PR and made false or stale by it in the SAME fixed release: the 2a face entry 18.filter-equality-array-comparand-refused.ts:66-67 ("deliberately left to its own ruling"), and the unreleased changesets 19757-equality-slot-array-refused.md:29, 19888-analytics-implicit-array.md:30, 19974-having-comparand-shape-face.md:30 ("so having still answers it" — now false), 19975-read-scope-eq-array.md:15, 20010-analytics-where-face-arms.md:28 ("so it compiles as before" — now false), 20035-analytics-where-type-face.md:35, 20099-having-where-doors.md:33 ("still answered until the shared face judges it" — now false). Precedent 6aa3188a corrected two sibling changesets in its own PR to say the other door "refuses the shape too" in this release. Non-blocking (release prose, no gate reads it, the seat's note route is legitimate), but the seat should carry the full list into the remainder or a release-time sweep rather than the two named.
  • Acceptance note 3, the dead objectql pass branch — CORRECT. engine-aggregate-having-comparand-shape.test.ts:297-308 is parity-by-construction on { total: { $ne: [500] } }; its else branch is dead and the test stays true and green.
  • Acceptance note 4, driver-memory's probe helper — CORRECT. memory-filter-ast-vocabulary.test.ts:104-114 catches the throw and answers undefined; the docblock (:95-102) names only the equality spellings; harmless.
  • Stage 2d untouched — CONFIRMED. Probe on head: $gt:["a"] and $in:[["a"]] still PASS the face; no code judges a { $field } referent to a multi-valued field; no driver, formula or objectql source in the diff.
  • What the author should also have flagged (the one substantive gap): the face's other callers. having (engine.ts:16244), the per-aggregation filter (engine.ts:16136) and the analytics read-scope compiler on both strategies (read-scope-sql.ts:687 via :892; objectql-strategy.ts:669,1165) now refuse a $ne array; the read-scope door moves from binding the array as a ? parameter under a SQL not-equals to READ_SCOPE_COMPILE_FAILED / 500 fail-closed. The changeset's before/after table and the entry's surface name only "the engine's lowering seam" and "the analytics where door". All three unstated doors move in the refusing direction, none is reachable from a published CEL policy since stage 2c, and the ruling's letter ("the shared comparand-shape face … for every driver") covers them, so this is a completeness flag for the release record, not a wrong judgment.
  • Security-labelled card, stated explicitly: no path now ACCEPTS something it refused before (evidence under ①, last bullet).
  • Minor: the PR is a draft with Part of #19886 and no closing keyword, correct while stage 2d and the carrier remainder stay open; the "Part-of PR must not also close its card" check is success.

Implemented-by: claude/issue-19886-ne-array-shared-face-and-schema-door
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 09:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit e7344f0 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19886-ne-array-shared-face-and-schema-door branch September 27, 2026 10:02
os-project-manager pushed a commit that referenced this pull request Sep 27, 2026
…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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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

Development

Successfully merging this pull request may close these issues.

2 participants