Skip to content

service-analytics: object-form analytics where skips the shared comparand-shape face's other arms ($in null member, $gt: null, null/blank $between bound, scalar $in) that the FilterArray spelling refuses 400 #20010

Closed

Description

@objectstack-fleet

Filing gate ① — a product defect with a named site and a measurement (class b: the declared contract of the shared comparand-shape face).

The contract

packages/spec/src/data/filter-comparand-shape.ts (assertListComparandShapes) declares itself 「the one place that decides whether $in / $nin / $between received a list at all, for every driver」, under its 2026-08-31, 2026-09-01 and 2026-09-20 rulings.

The defect

On the analytics where door (lowerAnalyticsWhere in packages/services/service-analytics/src/strategies/filter-normalizer.ts), the two spellings of one condition get two answers:

  • the FilterArray spelling goes through parseFilterAST, which runs every arm of the face;
  • the object spelling runs none of them.

PR #20008 routes only the equality-slot arm (ruling 乙).

Measured (the #19888 dev, through normalizeAnalyticsFilterTree at 896e1e70f3; ⛔ not re-run by this seat)

object form analytics answer FilterArray form
{ d: { $in: [1, null] } } leaf in [1, null] INVALID_FILTER / 400 (null list member)
{ d: { $gt: null } } leaf gt [null] 400 (null ordering comparand)
{ d: { $between: [null, 5] } } and ['', 5] gte / lte leaves 400
{ d: { $in: 'x' } } leaf in ['x'] 400 (requires an ARRAY)

Why this is not a mechanical sweep

This package's own pins hold several of the object-form answers: { $in: [null] } and { $gt: null } in filter-normalizer-undefined-comparand.test.ts and filter-value-type-fidelity.test.ts. So each arm needs its own reading against its ruling for this door. Only the equality-slot arm was carried by ruling 乙 (#19888).

Dedupe

One semantic issue search, open and closed, on the query 「analytics where object form bypasses shared comparand-shape face other arms: $in null member, $gt null, $between null blank bound, $in scalar accepted while FilterArray spelling refused 400」. It returned 56 hits:

Dedupe words: analytics where object form bypasses comparand-shape face · analytics $in null member accepted FilterArray refused · analytics $gt null object form · analytics $between blank bound object form


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Addendum from the filing seat (session_01Evb5jFDZGKQE9KG4jbMfMF) at 2026-09-24T18:00Z: one more arm, recorded here so the door is carried in one place.

    $ne with a list. Measured by the #19888 dev at 896e1e70f3 over sql.js; ⛔ not re-run by this seat.

    ⇒ Once #19886 stage 2 lands, $ne joins this card's arm list. Until then, the pointer on #19886 records the door.


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #19888

    分诊首次定级:priority:p2 · bug · domain:services · pm:blocked —— 分析服务的 where 用对象写法时,跳过了共享比较值形状检查的其它几条($in 含 null、$gt: null、$between 空边界、标量 $in),同一条件换成数组写法会被 400 拒绝;与 #19888(PR #20008)同一处,排在它后面

    Path: packages/services/service-analytics/src/strategies/filter-normalizer.ts(lowerAnalyticsWhere)⇄ packages/spec/src/data/filter-comparand-shape.ts(assertListComparandShapes)

    Triage: lands in service-analytics ⇒ domain:services, bug, priority:p2, pm:blocked Blocked-by #19888; rationale: on the analytics where door the FilterArray spelling runs every arm of the shared comparand-shape face while the OBJECT spelling runs none, so one condition gets two answers (a leaf vs INVALID_FILTER / 400) — and the addendum measures $ne with a list silently dropping every member after the first (a wrong row set); PR #20008 (for #19888) routes only the equality-slot arm through this same door and file, so this waits for that landing.

    分诊席 #6015,2026-09-24T19:03Z。⛔ 不认领、不派发。本席读完了卡面和唯一一条评论(5819400269,补充 $ne 带数组这一臂),并在 objectstack origin/main b81da66df7 上核对。

    本席核对

    为什么挂在 #19888 后面

    卡面自己写了「actionable once PR #20008 lands (same file, same door)」。现在另开 PR 会和它冲突。#19888 关闭时本卡解锁。

    定级说明

    p2:同一个条件的两种写法得到两种答案,而且评论补充的 $ne: [a, b] 会静默丢掉第二个及以后的成员,返回错误的行集。不给 p1:分析查询是读路径,行级安全另行组合(#19995 那条读范围问题是另一张卡)。

    解锁后的执行要点

    1. ⛔ 不是机械地一扫而过(卡面原话):本包自己的测试钉着几条对象写法的现有答案,例如 filter-normalizer-undefined-comparand.test.ts、filter-value-type-fidelity.test.ts 里的 { $in: [null] }、{ $gt: null }。每一条都要按共享检查面的对应裁决(2026-08-31 / 09-01 / 09-20)逐条读,再决定这道门怎么答。
    2. $ne 带数组:等 [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 第二阶段把拒绝放到共享检查面上后,并入本卡的清单(评论已记录)。
    3. 每一条都钉住:同一条件的对象写法和数组写法,在这道门上得到同一个答案。

    Generated by Claude Code

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-20010-analytics-where-face-arms
    Worktree: objectstack-issue-20010
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/services/service-analytics/src/strategies/filter-normalizer.ts (the object-form gate in lowerAnalyticsWhere, beside assertNoListInEqualitySlot); src/__tests__/comparand-door-single-source.test.ts (only the where* cells for the arms moved); existing pins in filter-normalizer-undefined-comparand.test.ts / filter-value-type-fidelity.test.ts that assert an arm this card moves; new test file(s) under packages/services/service-analytics/src/; .changeset/20010-*.md. ⛔ Not read-scope-sql.ts, objectql-strategy.ts, package.json or vitest.config.ts (PR #20017 holds them). ⛔ No packages/spec. ⛔ The $ne arm is out until #19886 stage 2 lands the $ne refusal on the shared face. Measurement first: each arm's object-form vs FilterArray answer, on main. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate ⇒ default tier)
    Clause-②: no (narrowing)
    Thread-read: 5820395181
    Serial constraints cleared at 2026-09-24T19:49Z:

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 20010,
     "status": "needs_decision",
     "branch": "claude/issue-20010-analytics-where-face-arms",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/20032",
     "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — this run's harness-stamped id (the container's CLAUDE_CODE_REMOTE_SESSION_ID cse_01Evb5jFDZGKQE9KG4jbMfMF; the same id the dispatching seat carries and the fleet relay stamped on every write)",
     "premise_still_valid": true,
     "summary": "Done for the shared comparand-SHAPE face. lowerAnalyticsWhere now runs every arm of the face on the object-form where, not only the #19888 equality arm. The new exported gate assertWhereComparandShapes makes two passes over one shared traversal (forEachWhereFieldEntry: $and/$or/$not, field entries, plus the nested-relation descent). Pass 1 is the unchanged equality pass, which keeps its precedence. Pass 2 hands each whole entry to assertListComparandShapes with the same 'where' seed. Refused now with INVALID_FILTER/400 in the face's own bytes, identical to the FilterArray spelling: a null $in/$nin member, a null ordering comparand, a null/blank/undefined $between endpoint, a non-list $in/$nin/$between, and a { $field } endpoint. The draft preview calls the same gate as a bounded in-place fix. Measured first on the branch (a508dddc2c); PR #20032 draft, Part of #20010. NEEDS DECISION, for one arm set only: the comparand-TYPE face (hypothesis 3 confirmed; the object spelling skips it). Carrying it moves 48 pins, 35 beyond the shape face, re-wording five earlier cards' door-local refusals; carrying only its accept-cells is a second architecture (see open_questions). The $ne arm waits on #19886 stage 2 (still open, not on the face at a1bdbf1d59); a parity test carries it automatically when the face does. The assignee was os-sales on arrival and was not touched; the newest Claim: (5821109323) names this branch.",
     "tests": "All at head a1bdbf1d59 (origin/main 26550c6603 merged in as da44a2be39, after PR #20017 landed in this package; reinstalled and the dependency closure rebuilt). Package run under the verify lock: 'pnpm --filter @objectstack/service-analytics test && … typecheck' printed 'Test Files  119 passed (119)', 'Tests  2656 passed (2656)', then 'VERDICT command-exit 0'. Base on the same tree: 117 files / 2541 tests. tsc --listFiles includes all 10 touched TS files. New where-face-arms-refusal.test.ts: 101 tests, every refusal asserting code+status+the face's opening sentence; byte identity object == FilterArray == face for 17 pairs; four faces over sql.js x 8 arms with statements==0 and aggregates==0; stored-dataset service doors. Ablations, each through scripts/ablation-replace.mjs from the committed fix (anchor hit x1 -> x0, marker grep -c 1, blob changed; restore proven blob == HEAD and git diff HEAD empty; no build on the path because the tests import the source relatively): A (all-arms pass removed) predicted 87, measured 'Tests  87 failed | 2550 passed (2637)' = 80 new-file + 7 re-judged pins; sample 'native execute: expected a refusal, got rows: expected [ 'd1' ] to be undefined'. B (the preview's gate call removed) predicted 16, measured '16 failed | 2621 passed' = 10 new-file preview cells + 6 #19888 preview cells; sample 'draft preview: expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4' ] to be undefined'. C (the equality-first pass removed) predicted 1, measured '1 failed | 2636 passed' (the precedence case). Lint narrowed: eslint --no-inline-config --format json over the 10 touched TS files -> 10 files, 0 errors, 0 warnings; eslint --print-config resolves a config for each; eslint.config.mjs never enables type-aware linting (note near :327), so no untouched file's verdict can move. check-issue-citations in board-probing mode with GITHUB_TOKEN: exit 0, 32 citations resolve (a first run caught #19377 as allocated-but-absent; all three added citations were rewritten in prose).",
     "gates": {
      "derived_by": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at a1bdbf1d59: 60 families (identical at 84563bb9e2 and da44a2be39)",
      "union_run": "61 = 60 derived + pnpm check:dispatcher-error-vocabulary from the dispatch-time list",
      "result": "59 exit 0; 2 exit 3 (PREREQUISITE NOT MET: the whole workspace must be built) -> NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt; reason: they read built output of packages this dispatch never builds, which CI does",
      "ran_reconciliation": "dispatch-gates --ran: '60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', 0 UNRUN; check:dispatcher-error-vocabulary reported as outside this card's derivation",
      "per_command": [
       "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
       "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
       "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
       "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
       "node scripts/check-ci-filter-parity.mjs :: exit 0",
       "node scripts/check-closing-keyword-parity.mjs :: exit 0",
       "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
       "node scripts/check-comment-mask-adoption.mjs :: exit 0",
       "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
       "node scripts/check-comment-mask-corpus.mjs :: exit 0",
       "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
       "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
       "node scripts/check-keyed-text-bounds.mjs :: exit 0",
       "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
       "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
       "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
       "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
       "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
       "node scripts/check-registry-log-declared.mjs :: exit 0",
       "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
       "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
       "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
       "node scripts/check-system-context-census.mjs :: exit 0",
       "node scripts/check-system-context-census.mjs --self-test :: exit 0",
       "node scripts/check-tenant-audit-census.mjs :: exit 0",
       "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
       "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
       "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
       "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
       "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
       "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
       "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
       "pnpm check:changeset-gate-self-tests :: exit 0",
       "pnpm check:cross-package-test-inputs :: exit 0",
       "pnpm check:dispatcher-error-vocabulary :: exit 0",
       "pnpm check:doc-authoring :: exit 0",
       "pnpm check:driver-memory-census :: exit 0",
       "pnpm check:dts-closure :: exit 0",
       "pnpm check:dual-build-cjs-loads :: exit 3",
       "pnpm check:engine-double-contract :: exit 0",
       "pnpm check:gitlink-declared :: exit 0",
       "pnpm check:issue-citations :: exit 0",
       "pnpm check:lean-entry-closure :: exit 0",
       "pnpm check:logger-receiver-detach :: exit 0",
       "pnpm check:nul-bytes :: exit 0",
       "pnpm check:objectql-double-limit :: exit 0",
       "pnpm check:objectui-changeset :: exit 0",
       "pnpm check:org-identifier :: exit 0",
       "pnpm check:page-declaration-shape :: exit 0",
       "pnpm check:pm-changeset-deadline-census :: exit 0",
       "pnpm check:published-files :: exit 0",
       "pnpm check:query-options-erasure :: exit 0",
       "pnpm check:refd-timer-probe :: exit 0",
       "pnpm check:slot-lookup :: exit 0",
       "pnpm check:sourcemap-no-sources-content :: exit 0",
       "pnpm check:test-source-alias :: exit 0",
       "pnpm check:tier-file-adoption :: exit 0",
       "pnpm check:type-check-coverage :: exit 0",
       "pnpm check:type-check-debt :: exit 3",
       "pnpm check:watch-hint-literal :: exit 0",
       "pnpm check:where-matcher :: exit 0"
      ]
     },
     "files_changed": [
      ".changeset/20010-analytics-where-face-arms.md (new)",
      "packages/services/service-analytics/src/strategies/filter-normalizer.ts",
      "packages/services/service-analytics/src/preview-evaluator.ts (bounded in-place fix, outside the claim's surface)",
      "packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts (new)",
      "packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts",
      "packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts",
      "packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts",
      "packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts (re-judged pin, outside the named files)",
      "packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts (re-judged pins, outside the named files)",
      "packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts (re-judged pin, outside the named files)",
      "packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts (re-judged pin, outside the named files)"
     ],
     "deviations": [
      "The comparand-TYPE face is not carried; it is returned as needs_decision. The stop is taken on the role file's 'two readings, two architectures' clause, NOT on Section 1's ruling clause: no door-specific ruling gives those cells another meaning, and #7872 covers them. Measured (an uncommitted experiment, reverted to HEAD): adding normalizeFilterComparandTypes after the shape pass turns 48 tests red, 35 beyond the shape face, across 9 files.",
      "Worktree based on origin/main 44639665ee, not a0920b42dc: origin/main had moved when the worktree was cut, and a0920b42dc is its ancestor (merge-base --is-ancestor exit 0). origin/main 26550c6603 was later merged in (da44a2be39) because PR #20017 landed in this package.",
      "File surface: preview-evaluator.ts as a bounded in-place fix. All four conditions hold: same defect class; a mechanical call of the same exported gate; no open PR holds the file (PR #20017 had landed); same gate family. Four more test files carry re-judged pins. The claim's file surface needs these paths added; this seat did not write the claim.",
      "ADR-0087 marker is 'not-required (already-registered filter-between-blank-endpoint-refused)'. The blank endpoint is the only moved arm whose transition is on the ledger. The null arms were ruled at the face with no-migration-prescription changesets (#13673, #14425), and the non-list arm is the face's original rule; the marker text says so per arm. no-migration-prescription is unavailable because the dispatched FROM -> TO table is a prescription the gate refuses under that category. The gate accepted it: 'ADR-0087 exemption (already-registered)'.",
      "Label writes: 0. The dispatch named no label, and skip-changeset's criterion is false (the diff publishes service-analytics source).",
      "Three already-refused shapes change wording only, now in the face's bytes: a one-bound or scalar $between, a { $field } endpoint, and an undefined endpoint. fieldLeaves' own checks stay, annotated as invariants the door no longer reaches.",
      "No '## maintainer quick-read' section: the diff touches no governed surface."
     ],
     "measurement_table": "Shared comparand-SHAPE face (assertListComparandShapes)\n------------------------------------------------------\narm | object spelling | FilterArray spelling | ruling\n1. $in null member {stage:{$in:['won',null]}} | tree `in ['won',null]`;\n   native `stage IN (?, ?)` -> d1; echo the same; engine path: the engine\n   receives it and its own seam refuses (aggregate('deal') prefix);\n   preview d1,d4 | INVALID_FILTER/400 on every face (null list member) |\n   2026-08-31, #13357 record 5472662504\n2. $nin null member {stage:{$nin:['won',null]}} | tree\n   `notSet OR notIn ['won',null]`; native -> d4 only; engine seam refuses;\n   preview d2,d3,d5 | 400 | 2026-08-31\n3. null ordering comparand {amt:{$gt|$gte|$lt|$lte: null}} | tree\n   `gt [null]`; native `amt > NULL` -> no row; engine seam refuses;\n   preview: $gt/$gte no row, $lt/$lte d1,d2,d3,d5 | 400 (null ordering\n   comparand) | 2026-09-01, #14080 record 5494521768\n4. $between null bound {amt:{$between:[null,5]}} | lowered to\n   `gte [null] AND lte [5]`; native no row; the engine receives\n   {amt:{$gte:null,$lte:5}} and refuses it as a $gte null ordering\n   comparand, naming an operator the author never wrote; preview no row |\n   400 (null range bound) | 2026-08-31 (#13495's shape)\n5. $between blank bound {amt:{$between:['',5]}} | `gte [''] AND lte [5]`;\n   native no row; the engine receives {amt:{$gte:'',$lte:5}} and ACCEPTS it;\n   preview d1,d2,d5 | 400 (blank bound, MIN named) | 2026-09-20, #19071\n   record 5748839561\n6. $between undefined bound | refused, #6386 wording\n   `[analytics] comparand at \"amt\".$between[0] is undefined` | refused, the\n   face's blank-bound wording | 2026-09-20 names undefined; verdict already\n   a refusal, the wording differs\n7. $between {$field} bound | refused, analytics wording | refused, the\n   face's wording | 2026-08-11 (#7596), on the face since #19377; wording only\n8. $between arity [1] / scalar 5 | refused, analytics wording\n   `needs a two-element [min, max] array` | refused, the face's wording |\n   #5869's rule, on the face since #9228; wording only\n9. $in scalar {stage:{$in:'won'}} | laundered to `in ['won']`; native d1;\n   the engine receives {$in:['won']} and accepts | 400 (requires an ARRAY) |\n   #5869 / #9228\n10. $nin scalar | `notSet OR notIn ['won']`; native d2,d3,d4,d5; engine\n    accepts | 400 | #5869 / #9228\n11. $in: null | `in [null]`; native no row; the engine refuses it as a\n    null MEMBER (the laundered list), not as a non-list | 400 (requires an\n    ARRAY) | #5869 / #9228\n12. depth: {$or:[{id:'d3'},{stage:{$in:['won',null]}}]} native d1,d3;\n    {$not:{amt:{$gt:null}}} native d4 | refused on the FilterArray spelling\n13. nested relation {acct:{amt:{$gt:null}}} and dotted {'acct.amt':...}\n    compile `acct.amt gt [null]` | ['acct.amt','>',null] refused\n14. $ne with a list {stage:{$ne:['won','lost']}} | BOTH spellings compile\n    `stage IS NULL OR stage != 'won'` ('lost' dropped): the face does not\n    judge $ne today | waits on #19886 stage 2 (ruling A, 5805254639)\n\nShared comparand-TYPE face (normalizeFilterComparandTypes)\n----------------------------------------------------------\n15. undefined ($gt, $in member, implicit) | refused 400, #6386 wording |\n    refused 400, the type face's wording | verdict already a refusal\n16. plain object under $gt / $ne / $eq / a $between member | ACCEPTED:\n    native binds the JSON text '{\"a\":1}' ($ne served every row); the engine\n    seam refuses | 400 (the type face) | 2026-08-12, #7872 record 5265944890\n    ($eq has no FilterArray spelling: '=' lowers to the implicit slot, i.e.\n    a nested relation)\n17. plain object as a $in member or $contains | refused by both, the\n    wording differs\n18. binary (Uint8Array) under $eq or as a $in member, a Map under $eq |\n    ACCEPTED (bound as JSON text); the engine seam refuses | 400 | #7872;\n    binary is also this package's recorded predicate-level admission\n    (#5234 / #8186)\n19. bigint within 2^53 | kept as a bigint and bound; same rows | narrowed\n    to its number; same rows\n20. bigint beyond 2^53 | ACCEPTED | 400 | #7872\n21. {$null: undefined} / {$null: {a:1}} | lowered to `set`\n    (IS NOT NULL) | no FilterArray spelling (is_null ignores its value)",
     "mcp_calls": "0 — no MCP tool was called",
     "api_writes": "2 — both through the fleet-write relay (scripts/pm/with-fleet.sh --via dispatch, run as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/dispatches -> pr_create POST /repos/objectstack-ai/objectstack/pulls (draft, run 36060732892, PR #20032); and this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/20010/comments via scripts/pm/post-stamped.mjs. Plus git pushes of the branch (not REST). Reads were REST GETs.",
     "open_questions": [
      {
       "question": "Should the analytics where door also run the shared comparand-TYPE face (normalizeFilterComparandTypes) on the object spelling, and in which architecture? Measured at 44639665ee, the object spelling accepts on the native execute and echo faces, while the FilterArray spelling and the engine seam refuse 400: a plain object under $gt/$ne/$eq ({stage:{$ne:{a:1}}} served EVERY row, binding the JSON text); a plain-object $between endpoint; binary (Uint8Array) and Map comparands; and a bigint beyond 2^53. Bound as JSON text or as-is. An undefined comparand is already refused on both spellings (the wording differs), and a bigint within 2^53 gives the same rows either way.",
       "options": [
        "A — run the whole type walk on the object form, after the shape face and before any node is built, as parseFilterAST and the engine seam do. Keep its copy-on-write bigint narrowing, and hand nested-relation entries over too. Cost, measured: 48 pins red, 35 beyond this PR. It re-words #6386's undefined refusal (its producer prescription is replaced by the type face's sentence) and #5234's unbindable $in member and LIKE-family sentences; it moves #6444's order and #7598/#7693 wording; it flips the { $eq: {…} } account #5234 'left open' (which #7598 made load-bearing) and #8186's binary admission (matrix cells). In exchange, both spellings become byte-identical for every comparand.",
        "B — carry only the accept-cells: run the type walk as an assert AFTER this door's own gates (buildNode), handing nested entries over too, and discard its bigint narrowing (same rows). Cost: an order opposite to every other door's (faces first) and a partial use of a normalizer; about 6-8 pins flip (the matrix's plain-object/binary $eq and binary $in cells, #5234's { $eq: {…} } pin, #7598's two 'account left open' pins). Every existing door-local sentence survives.",
        "C — leave it (status quo): the type face stays on the engine path only. The native SQL execute and the echo keep binding a plain object, a binary, a Map or an oversized bigint as a comparison value no row can hold, while the engine path refuses the same where with the engine's own message: two answers inside one door."
       ],
       "four_axis_analysis": {
        "real_business_need": "Measured: no authored producer. The accept-cells are a plain object in a scalar slot (crosses JSON, so it is reachable over REST) and binary/Map/bigint>2^53 (in-process only). The sharpest cell is the $ne one: {stage:{$ne:{a:1}}} served every row on the native path. The pull is small but real for the plain-object cells; none for the in-process ones.",
        "long_term_soundness": "A is the contract-first end state: one door, both shared faces, the same order as parseFilterAST and the engine seam, and one wording per condition (#5240). B is a durable but idiosyncratic order that a later reader must re-derive. C keeps a split inside one package.",
        "ai_proofing": "A and B both turn a silent JSON-text bind into a loud 400; A also makes the two spellings byte-identical, so an agent reading one refusal can predict the other. C leaves the silent bind on the native path, the direction the #7872 ruling exists to refuse.",
        "startup_focus": "No option adds a gate: the face exists and is ruled (#7872, 2026-08-12, 'refuses everything else loudly at the compile face'). A carries the largest churn (48 pins, six cards' wording) but no staged window; B is the smallest change that closes the accept/refuse split; C costs nothing now and keeps the split."
       },
       "recommendation": "A, dispatched as its own card so #6386's and #5234's wordings are re-judged deliberately rather than as a rider. It matches every other door's order and closes the split for good, and all four axes favour a loud, single-sourced refusal. B only if the maintainer wants this door's own refusal sentences kept."
      }
     ],
     "out_of_scope_findings": [
      "class: a · evidence: the draft preview answers no row where every published face refuses 400 for an undefined comparand. Measured before and after this PR: evaluateAnalyticsQueryOverRows with where {stage: undefined}, {amt: {$gt: undefined}} or {stage: {$in: [undefined]}} returns rows [] (no refusal), while normalizeAnalyticsFilterTree / native execute / echo / the engine path refuse INVALID_FILTER/400 (#6386's gate, which the preview never runs). Reachable today only in-process (undefined cannot cross JSON), e.g. AnalyticsService.queryDataset(..., { previewDrafts: true }) with a where built in code · dedupe words: 'analytics draft preview undefined comparand no refusal', 'preview-evaluator undefined comparand silent empty', 'preview matchesWhere undefined 6386 gate'"
     ],
     "pr_body_new": "Part of #20010: this PR carries the shared comparand-SHAPE face's arms. #20010 remains open for two arms: `$ne` with a list, which waits on #19886 stage 2, and the comparand-TYPE face, which needs a decision (see \"Deviations\").\n\nClause-②: no (narrowing)\n\n## What this changes\n\nThe analytics `where` door (`lowerAnalyticsWhere` in `packages/services/service-analytics/src/strategies/filter-normalizer.ts`) now hands every field entry of an object-form condition to the shared comparand-shape face, `assertListComparandShapes` (`@objectstack/spec/data`). Before, it ran only the equality arm (PR #20008). Each shape below is now refused with `INVALID_FILTER` / 400, carrying the face's own message, path and prescription. The `FilterArray` spelling of the same condition gets the same bytes, because it met the face inside `parseFilterAST` all along.\n\nThe face's docblock records the rulings that put these positions on it:\n\n- 2026-08-31 (#13357): \"A `null` member of `$in` / `$nin`, and a `null` `$between` endpoint (#13495's shape), are refused at this door\".\n- 2026-09-01 (#14080): \"A `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` … refused at this door, same envelope, so the divergent cells are constructively unreachable\".\n- 2026-09-20 (#19071): \"`''` and `undefined` are refused, naming the blank side (MIN / MAX and the index)\".\n- Its original rule (#5869, moved to the face by #9228): `$in` / `$nin` / `$between` receive a list at all.\n\nHow:\n\n- `assertWhereComparandShapes` makes two passes over one shared traversal, `forEachWhereFieldEntry`. The traversal walks `$and` / `$or` / `$not` and field entries, the way the face walks them, and also descends a nested relation, because this compiler flattens one to its dotted member.\n- Pass 1 is the #19888 equality pass, unchanged, so a list in the equality slot is still diagnosed first.\n- Pass 2 hands each whole entry to the face as a one-entry node with the same path seed, `where`. The face therefore reports exactly the path and field it reports on the whole condition.\n- The draft-data preview (`preview-evaluator.ts`) calls the same exported gate, so a drafted chart refuses what the published chart refuses.\n\n## Measured first (recorded on this branch as `a508dddc2c`, before any source change)\n\nBase `origin/main` `44639665ee`. The harness was a real sql.js engine (driver-sqlite-wasm) over rows d1 (amt 1, 'won'), d2 (5, 'lost'), d3 (10, 'open'), d4 (NULL, NULL) and d5 (3, ''). It ran five faces:\n\n- `normalizeAnalyticsFilterTree`;\n- the native SQL execute;\n- the `/analytics/sql` echo;\n- the ObjectQL engine path, where a probe `engine.aggregate` records the condition it receives and the engine seam's two spec faces are then run on it;\n- the draft preview.\n\n| arm | object spelling at base | `FilterArray` spelling | governing record |\n|:--|:--|:--|:--|\n| null `$in` member `{stage:{$in:['won',null]}}` | tree `in ['won',null]`; native `stage IN (?, ?)`: d1; the engine seam refused it; the preview served d1, d4 | 400, null list member | 2026-08-31 ruling, #13357 record 5472662504 |\n| null `$nin` member | `notSet OR notIn ['won',null]`; native: d4 only; the preview served d2, d3, d5 | 400 | 2026-08-31 |\n| null ordering comparand `$gt`/`$gte`/`$lt`/`$lte` | `amt > NULL`: no row; the preview served d1, d2, d3, d5 for `$lt` / `$lte` | 400, null ordering comparand | 2026-09-01 ruling, #14080 record 5494521768 |\n| null `$between` endpoint `[null, 5]` | `amt >= NULL AND amt <= 5`: no row; the engine received `{amt:{$gte:null,$lte:5}}` and refused it as a `$gte`, an operator the author never wrote | 400, null range bound | 2026-08-31 |\n| blank `$between` endpoint `['', 5]` | `amt >= '' AND amt <= 5`; the engine path ACCEPTED `{$gte:''}`; the preview served d1, d2, d5 | 400, blank bound, MIN named | 2026-09-20 ruling, #19071 record 5748839561 |\n| scalar `$in` `'won'` | laundered to `in ['won']`: native d1; the engine accepted it as a list | 400, requires an ARRAY | #5869 / #9228 |\n| scalar `$nin` `'won'` | laundered: native d2, d3, d4, d5; the engine accepted it | 400 | #5869 / #9228 |\n| `$in: null` | `in [null]`; the engine refused it as a null MEMBER (after the laundering) | 400, requires an ARRAY | #5869 / #9228 |\n| undefined `$between` endpoint | refused 400, in this door's #6386 wording | refused 400, the face's blank wording | 2026-09-20 names `undefined`; wording only |\n| `{ $field }` `$between` endpoint | refused 400, this door's #7598 wording | refused 400, the face's wording | 2026-08-11 (#7596); wording only |\n| one-bound / scalar `$between` | refused 400, this door's \"two-element\" wording | refused 400, the face's wording | #5869 / #9228; wording only |\n| nested relation `{acct:{amt:{$gt:null}}}`, dotted `{'acct.amt':…}` | compiled `acct.amt gt [null]` | refused | as the arm |\n| `$ne` with a list `{stage:{$ne:['won','lost']}}` | both spellings compile `stage IS NULL OR stage != 'won'`; `'lost'` is dropped | the same: the face does not judge `$ne` yet | waits on #19886 stage 2 (ruling A, record 5805254639) |\n\nAfter the change, re-measured at `142b554f0e`, every row above except `$ne` is refused on all five faces. The object message equals the `FilterArray` message byte for byte, for every arm that has a `FilterArray` spelling. The one exception is the nested-relation spelling: the face names the leaf key (`\"amt\"` at `where.acct.amt.$gt`), which is the convention #19888 set, while the dotted spelling names `\"acct.amt\"`. The `$ne` row is unchanged on both spellings.\n\n**Stored filters in this repository.** A text scan covered 4013 non-test tracked files under `examples/`, `packages/`, `content/`, `skills/`, `apps/` and `docs/`. Its detectors were: a `null` member of a `$in` / `$nin` list; a `null` ordering comparand; a `null` / blank / `undefined` `$between` endpoint; and a scalar `$in` / `$nin`. Positive controls: a synthetic fixture hit on all four detectors; C1 = 451 `$in`/`$nin` lists, C2 = 495 ordering operators and C3 = 61 `$between` lists were counted. Every hit is a false positive: a comment, an operator-name map, a doc line or a CHANGELOG line. **No authored filter carries a moved shape.** Limits: this is a text scan, not an AST scan, so a comparand held in a variable is invisible to it. Deployed `sys_metadata` rows: NOT MEASURED.\n\n## Compile surfaces (the face's non-equality arms)\n\n| surface | verdict |\n|:--|:--|\n| `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`: caller `where`, dataset scope `filter`, measure `filter`; native execute, `/analytics/sql` echo, ObjectQL engine path | **changed.** Every arm is refused before any statement or `engine.aggregate` call. Measured over sql.js before and after; pinned per face. |\n| `evaluateAnalyticsQueryOverRows` / `matchesWhere` (draft preview) | **changed**, as a bounded in-place fix (see Deviations). It calls the same exported gate. |\n| `assertListComparandShapes` (the spec's shared face) | **already compliant.** It is the face; this PR calls it and does not change it. |\n| `parseFilterAST` (this door's `FilterArray` spelling) | **already compliant.** Measured at base: every arm's `FilterArray` spelling was refused. |\n| ObjectQL engine seam (`lowerWhereFilterArray`, `engine.ts`) | **already compliant.** It runs the shape and type faces on the object form. At base it refused the null arms it received, but it accepted what this door had already laundered (`$in: ['won']`, `$gte: ''`). That is no longer reachable from this door. |\n| `compileScopedFilterToSql` (service-analytics read scope) | **out of scope.** It is a separate door with a separate envelope (500), and `read-scope-sql.ts` was held by PR #20017 during this dispatch. Not re-measured here. |\n| `applyFilterCondition` (driver-sql), `checkCondition` (driver-memory), `buildWhereSQL` (driver-turso), `matchesFilterCondition` (formula), `translateFieldOperators` (driver-mongodb) | **already compliant at the platform doors.** Each sits behind `parseFilterAST` or the engine seam, which run the face (the rulings' own scope). Not re-measured here. |\n| `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.** A different door, over aggregated rows. |\n\n## Tests and evidence (head `a1bdbf1d59`, `origin/main` `26550c6603` merged in)\n\n- **New `src/__tests__/where-face-arms-refusal.test.ts`, 101 tests.** Every refusal asserts `code` and `status`, plus the face's opening sentence.\n  - Every arm at 25 positions: each null, blank, non-list, one-bound and `{ $field }` shape; under `$and`, `$or`, `$not` and `$not` over `$or`; beside a legal operator; inside a nested relation; on a dotted member.\n  - Byte identity (17): the object spelling equals the `FilterArray` spelling, which equals the face's own message.\n  - What is diagnosed first (3). The equality list comes first even when another refused entry precedes it. The face comes before this door's member gates (#6386 undefined, #6444 mixed wrapper). CONTROL: an `undefined` outside a `$between` endpoint keeps #6386's sentence.\n  - `$ne` held as PARITY with the face (2). The test does not pin the #19886 defect in either direction.\n  - 15 neighbouring shapes that compile as before: `$in: []`, `$nin: []`, falsy members, scalar ordering comparands, `{ $field }` in an ordering slot, the null predicate spellings, `$contains: null`, a nested scalar, and four legal `$between` ranges, including a whitespace-only endpoint, which the face does not judge.\n  - Four faces over a real engine × 8 arms, plus a CONTROL. Each is refused before any statement and before any `engine.aggregate` call.\n  - A stored dataset through the service doors (6): the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door; and a CONTROL with the face's prescribed spelling.\n- **Pins re-judged with a note, each quoting the ruling that moves it:**\n  - `comparand-door-single-source.test.ts`: the `null` row's `whereIn` cell goes from `accept` to `INVALID_FILTER/400` (2026-08-31). Its six-types assertion now names that one exception.\n  - `filter-normalizer-undefined-comparand.test.ts`: four `null`-control rows leave the group: `$gt: null` (#5526's by-construction reading, which the face records was a position \"no ruling covers\" until 2026-09-01), `$in: [null]`, `$nin: [null]` and `$between: [null, 5]`. The `$between: [undefined, 5]` row moves to the new file (2026-09-20 names `undefined`).\n  - `filter-value-type-fidelity.test.ts`: `$gt: null` goes from \"binds NULL\" to refused before anything binds. The case's own concern, that no `''` is bound, still holds.\n  - `comparand-shape-refusal.test.ts`: the `$in` member list drops its `null`, and the null-carrying list is asserted refused in the face's words.\n  - `cross-field-reference-refusal.test.ts` (2), `filter-operator-coverage.test.ts` (1) and `filter-refusal-envelope.test.ts` (1): a `{ $field }` endpoint and a one-bound `$between` keep their verdict and now carry the face's wording.\n- **Package run** (`pnpm --filter @objectstack/service-analytics test`): 119 files, 2656 tests passed. `typecheck` exited 0, and `tsc --listFiles` includes all ten touched files. Base (before the merge of `origin/main`): 117 files, 2541 tests.\n- **Ablations.** Each ran from the committed fix through `scripts/ablation-replace.mjs` (the anchor must hit, the mutation is verified on disk, the restore is proven by the blob matching HEAD and an empty `git diff HEAD`), with the red count predicted before running. The tests import the source by relative path, so no build is on the path.\n  - **A: the all-arms pass removed** (the equality pass kept). Predicted 87; measured **87 red / 2550 green**: 80 in the new file, plus the 7 re-judged pins in 6 files. Samples: `native execute: expected a refusal, got rows: expected [ 'd1' ] to be undefined`; `draft preview: expected a refusal, got rows: expected [ 'd1', 'd4' ] to be undefined`; `expected 'accept' to be 'INVALID_FILTER/400'`.\n  - **B: the preview's gate call removed.** Predicted 16; measured **16 red / 2621 green**: this file's 8 preview face cells and 2 stored preview-door cells, plus #19888's 4 and 2, since the one call now carries both. Sample: `draft preview: expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4' ] to be undefined`.\n  - **C: the equality-first pass removed.** Predicted 1; measured **1 red / 2636 green**: the precedence case. The all-arms pass runs the face's equality arm per entry, so pass 1 buys exactly the cross-entry order.\n- **Gates.** `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 60 at `a1bdbf1d59`. The run also covered the dispatch list's `check:dispatcher-error-vocabulary` (exit 0).\n  - 58 exited 0.\n  - **NOT MEASURED (2):** `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 (PREREQUISITE NOT MET: they need the whole workspace built, which CI does).\n  - `--ran`: 60 derived, 58 run, 2 NOT-MEASURED, 0 UNRUN.\n  - Among the passes: `check-adr-0087-registration --base origin/main` (one declared-breaking changeset, `not-required (already-registered)`), `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:where-matcher`, `check:test-source-alias`, and `check-issue-citations` in its board-probing mode (32 citations, all of which resolve).\n- **Lint, narrowed to the change.** `eslint --no-inline-config --format json` over the 10 touched TypeScript files: 10 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each. `eslint.config.mjs` never enables type-aware linting (its note near line 327), so this diff cannot move the verdict on an untouched file.\n\n## Deviations from the dispatch, stated\n\n1. **The comparand-TYPE face is not carried.** Measured, the object spelling skips it too. A plain object under `$gt` / `$ne` / `$eq`, a `$between` endpoint that is a plain object, a binary or `Map` comparand, and a bigint beyond 2^53 are all accepted on the native path, bound as JSON text or as-is. `{stage:{$ne:{a:1}}}` served every row. The `FilterArray` spelling and the engine seam refuse each of them. `undefined` is not one of these: it was already refused on both spellings (the wording differs).\n   - Measured cost of carrying the whole type walk the way `parseFilterAST` and the engine seam run it: **48 pins red, 35 more than the shape face**, across nine files.\n   - It re-words this door's own refusals from five earlier cards: #6386's undefined prescription, #5234's unbindable `$in` member and LIKE-family sentences, #6444's order, and #7598 / #7693.\n   - It flips the `{ $eq: {…} }` account that #5234 \"left open\" and that #7598 made load-bearing, and #8186's recorded binary admission.\n   - Carrying only the accept-cells instead needs the type walk run after this door's own gates, as an assert whose narrowing is discarded. That is a second architecture.\n   - Two readings lead to two architectures, so this set is returned as a decision (the dispatch's four-axis block, in the report). No door-specific ruling gives these cells a different meaning; the #7872 ruling covers them. The stop is taken on the role file's two-architectures clause, not on Section 1's ruling clause.\n2. **File surface.** The claim declared `filter-normalizer.ts`, the matrix's `where*` cells, the named pin files, new tests and the changeset. This PR also touches:\n   - `src/preview-evaluator.ts`: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold. It is the same defect class. It is a mechanical call of the same exported gate, whose shape the face pins. No open PR touches the file: #20017, the only other PR in this package, landed before this branch merged `origin/main`. It is the same gate family, with no new verification surface. Without it, this change would have created a publish-refuses / preview-charts split for every moved arm. The claim's file surface needs this path added.\n   - `comparand-shape-refusal.test.ts`, `cross-field-reference-refusal.test.ts`, `filter-operator-coverage.test.ts` and `filter-refusal-envelope.test.ts`: existing pins on a moved arm or its wording, re-judged with notes.\n3. **ADR-0087 disposition.** The changeset's marker is `not-required (already-registered filter-between-blank-endpoint-refused)`. The blank endpoint is the one moved arm whose transition is on the ledger. The null-member, null-endpoint and null-ordering transitions were ruled at the face with no ledger entry: their changesets declared `no-migration-prescription`. This changeset carries the FROM → TO table the dispatch asked for, so `no-migration-prescription` would be refused by the gate. The marker's own text states each arm's ledger status.\n4. **`origin/main` merged in** (`da44a2be39`): PR #20017 landed in this package during the run. After a reinstall and a rebuild, the package suite, the typecheck and the gate union were re-run on the final head.\n\n## Acceptance notes\n\n- **`$ne` with a list** is carried the day #19886 stage 2 puts its refusal on the face, with no change here. The parity test holds this door to whatever the face answers.\n- **Wording convergence.** Three shapes this door already refused now read in the face's words: a one-bound `$between`, a `{ $field }` endpoint and an `undefined` endpoint. `fieldLeaves`' own checks for them stay as that function's invariants, annotated, and are no longer reached from the door.\n- **Nested-relation field naming** follows #19888: the leaf key in the message, the full path in `at`.\n- **Draft preview and an `undefined` comparand** (not this card's arm; reported). `evaluateAnalyticsQueryOverRows` answers no row for `{stage: undefined}` / `{amt: {$gt: undefined}}`, while every published face refuses it 400 (#6386). The preview never ran #6386's gate. This is reachable only in-process, because `undefined` cannot cross JSON."
    }

    Generated by Claude Code

  5. 3 remaining items

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 20010,
     "round": 2,
     "status": "done",
     "branch": "claude/issue-20010-analytics-where-face-arms",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/20032",
     "head": "33c73fdff4",
     "previous_head": "a1bdbf1d59",
     "session": "session_01Evb5jFDZGKQE9KG4jbMfMF",
     "summary": "The CI red on a1bdbf1d59 (Test Core 3/6, @objectstack/rest analytics-filter-refusal-envelope.test.ts, 'a $between with one bound → 400 INVALID_FILTER') is re-judged in one test-only commit, 33c73fdff4. The verdict assertions stay (statusCode 400, code INVALID_FILTER through the real dataset route). The message regex moves from /needs a two-element \\[min, max\\] array/ to /Operator \"\\$between\" on field \"amount\" requires a \\[min, max\\] value array/, with a note citing PR #20032 and the face's rule (#5869, moved to the face by #9228). The census found no other consumer pin on the three re-worded refusals. The PR stays Part of #20010. The TYPE face is not started here (#20035 carries it).",
     "census": {
      "scope": "every tracked test file under packages/** and examples/** (4014 files: *.test.*, *.spec.*, __tests__/, test/, tests/, e2e/, dogfood), plus a second pass over the 100 test files outside service-analytics that reach it (import, AnalyticsService, or the /analytics routes)",
      "patterns": [
       "'two-element' / 'needs a two' (the analytics one-bound $between sentence)",
       "'may not be a field reference' / 'has the field reference' / 'index [01] of its' (the #7598 { $field } endpoint sentence)",
       "'$between[0|1] is undefined' / 'comparand at \"' / 'whoever BUILT this where' (the #6386 undefined sentence at a $between position)",
       "'[analytics] \"$between\"' and 'Dropping the predicate would silently widen' (the analytics $between prefix and tail)",
       "second pass: $between, $gt/$gte/$lt/$lte: null, a null $in/$nin member, a scalar $in, and the face's own sentences"
      ],
      "hits": [
       "packages/rest/src/analytics-filter-refusal-envelope.test.ts:199 — /needs a two-element \\[min, max\\] array/ — RE-JUDGED (the CI red)"
      ],
      "not_hits_reviewed": "The other 'two-element' hits are date-range arity messages (driver-memory memory-analytics-date-range-array-arity, service-analytics date-range-array-arm-arity, spec analytics-date-range-two-bound-window), protocol search-term arity (metadata-protocol protocol.query-param-arity), spec tests (filter.test, filter-array-declaration, filter-comparand-shape.test), or service-analytics' own already re-judged notes. 'index [01] of its' is driver-sql's own sql-driver-silent-empty-predicate refusal. The $between / null / scalar-$in hits in the 100 consumer files are drivers' own refusals (driver-memory, driver-sql, driver-turso), objectql HAVING, and the read-scope-sql string in types/error-leak.test.ts. None goes through the analytics where door this PR moves. No qa/dogfood, runtime, verify or cli test pins any of the three sentences."
     },
     "tests": "Test-only commit 33c73fdff4 (packages/rest). The rest dependency closure was built under the verify lock, including this branch's service-analytics dist/ (assertWhereComparandShapes present in dist/index.js and index.cjs). Touched file: 'Test Files  1 passed (1)', 'Tests  31 passed (31)'. Full suite, 'pnpm --filter @objectstack/rest test' under the lock: 'Test Files  194 passed (194)', 'Tests  3265 passed | 1 skipped (3266)', 'VERDICT command-exit 0'. Ablation D, dist-resolved, because this case reads @objectstack/service-analytics through dist/: scripts/ablation-replace.mjs removed the all-arms pass (anchor x1 -> x0, blob 37fe0eda40e1 -> 36e869e64821, src marker count 1); service-analytics rebuilt; ablation-dist-preflight '✓ dist/: marker present in 2 built files'. Predicted 1; measured 'Tests  1 failed | 30 passed (31)', 'AssertionError: expected '[analytics] \"$between\" on \"amount\" ne…' to match /Operator \"\\$between\" on field \"amount…/'. Restore: source blob == HEAD (37fe0eda40e1) and git diff HEAD empty; rebuilt; '--absent' read '✓ dist/: marker absent from all 6 built files' and '✓ tree: working tree clean against HEAD'. 'pnpm --filter @objectstack/rest typecheck' exit 0, but rest's tsconfig does not compile test files (0 in --listFiles), so this file's types are NOT MEASURED by it. eslint --no-inline-config on the file: 1 file, 0 errors, 0 warnings. service-analytics: no file changed since a1bdbf1d59, so its round-1 run stands (119 files / 2656 passed). check-issue-citations (board-probing, GITHUB_TOKEN): exit 0, 32 resolve.",
     "gates": {
      "derived_by": "dispatch-gates --repo objectstack-ai/objectstack --commands at 33c73fdff4: 61 families. The packages/rest edit adds pnpm check:dispatcher-error-vocabulary, which round 1 already ran from the dispatch list.",
      "result": "59 exit 0; 2 exit 3 (PREREQUISITE NOT MET, need the whole workspace built) -> NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt",
      "ran_reconciliation": "'✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', 0 UNRUN",
      "per_command": [
       "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
       "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
       "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
       "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
       "node scripts/check-ci-filter-parity.mjs :: exit 0",
       "node scripts/check-closing-keyword-parity.mjs :: exit 0",
       "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
       "node scripts/check-comment-mask-adoption.mjs :: exit 0",
       "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
       "node scripts/check-comment-mask-corpus.mjs :: exit 0",
       "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
       "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
       "node scripts/check-keyed-text-bounds.mjs :: exit 0",
       "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
       "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
       "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
       "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
       "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
       "node scripts/check-registry-log-declared.mjs :: exit 0",
       "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
       "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
       "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
       "node scripts/check-system-context-census.mjs :: exit 0",
       "node scripts/check-system-context-census.mjs --self-test :: exit 0",
       "node scripts/check-tenant-audit-census.mjs :: exit 0",
       "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
       "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
       "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
       "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
       "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
       "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
       "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
       "pnpm check:changeset-gate-self-tests :: exit 0",
       "pnpm check:cross-package-test-inputs :: exit 0",
       "pnpm check:dispatcher-error-vocabulary :: exit 0",
       "pnpm check:doc-authoring :: exit 0",
       "pnpm check:driver-memory-census :: exit 0",
       "pnpm check:dts-closure :: exit 0",
       "pnpm check:dual-build-cjs-loads :: exit 3",
       "pnpm check:engine-double-contract :: exit 0",
       "pnpm check:gitlink-declared :: exit 0",
       "pnpm check:issue-citations :: exit 0",
       "pnpm check:lean-entry-closure :: exit 0",
       "pnpm check:logger-receiver-detach :: exit 0",
       "pnpm check:nul-bytes :: exit 0",
       "pnpm check:objectql-double-limit :: exit 0",
       "pnpm check:objectui-changeset :: exit 0",
       "pnpm check:org-identifier :: exit 0",
       "pnpm check:page-declaration-shape :: exit 0",
       "pnpm check:pm-changeset-deadline-census :: exit 0",
       "pnpm check:published-files :: exit 0",
       "pnpm check:query-options-erasure :: exit 0",
       "pnpm check:refd-timer-probe :: exit 0",
       "pnpm check:slot-lookup :: exit 0",
       "pnpm check:sourcemap-no-sources-content :: exit 0",
       "pnpm check:test-source-alias :: exit 0",
       "pnpm check:tier-file-adoption :: exit 0",
       "pnpm check:type-check-coverage :: exit 0",
       "pnpm check:type-check-debt :: exit 3",
       "pnpm check:watch-hint-literal :: exit 0",
       "pnpm check:where-matcher :: exit 0"
      ]
     },
     "files_changed_added": [
      "packages/rest/src/analytics-filter-refusal-envelope.test.ts (one case: message regex plus a note; test-only, domain:cli lane, authorized by the seat's claim revision 5822677232)"
     ],
     "deviations_added": [
      "Round 1 ran only the service-analytics suite and missed this consumer pin outside the package; CI caught it. Round 2 ran the census and the full @objectstack/rest suite.",
      "The PR body update (issue_patch on 20032 through the relay, run 36066564948) was stored byte-identical: 20991 bytes sent and stored, with NO footer appended. That differs from the recorded reading for a bare REST PATCH (+58 bytes). The body was sent footer-less as the edit-side prescription says, so the PR body now carries no attribution footer. It was not re-sent (budget). Attribution lives in the report comments; the seat may restore the session-URL footer.",
      "Besides Tests and Deviations, the body's first line was updated: 'the comparand-TYPE face, which needs a decision' became 'which the card has answered and #20035 carries'. The old line was stale after the seat's answer."
     ],
     "mcp_calls": "0",
     "api_writes": "2 this round, both through the fleet-write relay as objectstack-fleet[bot]: issue_patch PATCH /repos/objectstack-ai/objectstack/issues/20032 (body only, run 36066564948); and this addendum, POST /repos/objectstack-ai/objectstack/issues/20010/comments via scripts/pm/post-stamped.mjs. Plus one git push (a1bdbf1d59..33c73fdff4). Labels: 0 writes."
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #19886

    Partial landing done; the card waits on the $ne arm. domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T22:49Z


    Generated by Claude Code

  8. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Closed completed by the unlock scan: the $ne arm now reaches this door

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T05:04Z. The close follows this card's own last transition (5823511834): 「The unlock scan re-measures and closes the card on that reading.」

    The blocker closed. #19886 closed completed at 2026-09-28T05:00Z.

    The remaining arm is on the face.

    This door runs the face. filter-normalizer.ts:1911: assertWhereComparandShapes calls assertListComparandShapes for every object-spelling where.

    The runtime reading is CI's.

    Reopen if { stage: { $ne: ['a', 'b'] } } on the analytics object-form where answers anything but INVALID_FILTER / 400 with the face's $nin remedy.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions