fix(spec)!: a comparand the comparand-type face refuses is refused on save, and every charted presentation filter judges its nested relations (#20116) - #20325
Conversation
…module A verbatim move of `refuseNestedRelationComparands` and `analyticsCarrierFilter` (with their two predicates) out of `ui/dataset.zod.ts` into `ui/analytics-carrier-filter.ts`, a non-barrel module, so a second analytics carrier can share the one declaration. `DatasetSchema` and `DatasetMeasureSchema` call it exactly as before. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
… a widget filter is an analytics carrier M-type: `reportQueryFaceRefusals` asks the comparand-type face (`normalizeFilterComparandTypes`) after the shape face, read-only, so a plain object where a literal belongs, a Map, a class instance, undefined, a function, a Symbol or a bigint beyond 2^53 is refused on save at every reach the save doors have, in the face's words less its location. One slot raises one refusal, in the query doors' order (shape, type, flag). M-widget (dashboard half): `DashboardWidgetSchema.filter` declares the analytics carrier filter, so a comparand the analytics door refuses inside a nested relation is refused on save, as on the dataset carriers. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…y analytics carrier Stage 1's table-driven pin now counts the type face among the query faces (operator arms derived from FieldOperatorsSchema, every declared operator judged by the type face), walks the type face's own conformance table, pins its words less the location and the one-issue-per-slot order, and runs the nested-relation table on the dashboard widget filter too. The two report runtimeFilter carriers are listed as expected-open rows. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…and its HTTP-door pins - ADR-0087 semantic entry filter-comparand-types-and-widget-nested-slots-refused-at-save and the regenerated registry. - dropped-refinements.baseline.json: the widget filter's nested-relation walk is a dropped refinement at ui/DashboardWidget filter, ui/Dashboard widgets.element.filter and the four installed-package manifests. - The changeset (Clause-2: no (narrowing), BREAKING, registered). - rest: the analytics routes' schema door refuses the type face's JSON-representable cells, located on the member. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…age-2-type-and-widget
…the DTS build types it `isDataObject` / `isAnalyticsDataObject` take an object and sit behind the existing plain-node predicate, so the operator-map branch keeps its `Record<string, unknown>` narrowing. No verdict moves. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
dropped-refinements.baseline.json conflicted on the measured header; main's side is taken here and the branch's sites are re-added in the next commit from the build's own corrected entries. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…cs carriers ReportSchema.runtimeFilter and JoinedReportBlockSchema.runtimeFilter declare analyticsCarrierFilter(), so a comparand the analytics where door refuses INSIDE a nested relation is refused on save there too, as on the dataset and widget carriers. The parity pin's two EXPECTED_OPEN rows move into CARRIERS; the changeset and the semantic entry widen to the five carriers and drop the one-open-position warning; the one-issue-per-slot dedupe is named. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…e analytics carriers Applied from build-schemas' own "corrected entries" output on top of main's side of the ledger: the widget filter, the report and joined-block runtimeFilter, and the same positions in the four installed-package envelopes (+17 sites, 0 removed; measured 605 to 622). Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…age-2-type-and-widget
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d462665a8a43d1782446b5df4ce5d66ac77e238e && git checkout d462665a8a43d1782446b5df4ce5d66ac77e238e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dfd8e398aacfa74b8f401a9186814fec093cf010 830a071a4977276b0bb66b69f3861bb4c09c2ed7 && git checkout -B drift-repro dfd8e398aacfa74b8f401a9186814fec093cf010 && git merge --no-ff 830a071a4977276b0bb66b69f3861bb4c09c2ed7
node scripts/docs-audit/affected-docs.mjs --json dfd8e398aacfa74b8f401a9186814fec093cf010
|
|
CI:
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…age-2-type-and-widget
The changeset and the semantic entry said a slot a face refuses never carries a second issue. That holds at the top level and in the combinators; inside a nested relation on an analytics carrier the schema door's own $icontains and date-preset arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Text only; no verdict moves. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…de every clocked window (objectstack-ai#20366) Fixes objectstack-ai#20327 Clause-②: no Four `packages/client` suites paid the worker's one-time cold start inside their first clocked test window: better-auth's lazily imported module graph, the sql.js WASM compile, and the first-use costs of the sync and the sign-up. One of them, `auth-get-session-envelope.test.ts`, timed out at 5000 ms on the required `Test Core (5/6)` shard of PR objectstack-ai#20325, whose diff does not reach the package. This PR ports PR objectstack-ai#20272's shape to all four: each file pays its own arrangement once at module scope, during collection, which no vitest clock covers, and pins that placement. `auth-rotated-session-token.test.ts` also loses its seven explicit `60_000` per-case timeouts. No other number is raised, and there is no retry, skip or quarantine. The diff is four test files. This card carries the family (triage note 2): the census below covers all 50 `packages/client/src/*.test.ts` files. ## Premise check (on `origin/main` at `c74de10a9`) Holds. In `auth-get-session-envelope.test.ts`, the first case calls `await signedIn()` inside its `it` (`:262`-`:264`), and every case builds a fresh `SqliteWasmDriver` engine, a real `AuthManager`, and five of the seven perform a real sign-up. `premise_still_valid: true`. The red CI run itself (job `108717176436`) shows the same shape in two more files of the family: | suite, in the red `Test Core (5/6)` run | first case | later cases doing the same arrangement | |:---|---:|---:| | `auth-get-session-envelope` | timed out at 5000 ms | 583-1255 ms | | `organization-invitation-resend-team-placement` | 3599 ms | 486-820 ms | | `organization-invite-role-default` | 2849 ms | 497-820 ms | ## Census: every `packages/client/src/*.test.ts` (50 files) The criterion (dispatch, Zone 2): a suite that boots an engine (a `SqliteWasmDriver` engine, a real `AuthManager`, or a real sign-up) inside its first clocked `it`, or in a `beforeAll`/`beforeEach`. | files | shape | verdict | |:---|:---|:---| | `auth-get-session-envelope` | 7 cases, all through `signedIn()`/`anonymous()`: fresh engine + real `AuthManager`; default 5000 ms; the first case paid the cold start | **included** | | `auth-rotated-session-token` | 7 cases, all through `signedIn()`; an explicit `60_000` on all 7 cases, which widened the first case's window instead of moving the cost out | **included**; the `60_000`s removed | | `organization-invitation-resend-team-placement` | 5 of 11 cases through `arrange()` (fresh engine, real `AuthManager`, real sign-up, an org and a team); default 5000 ms; `①` paid the cold start | **included** | | `organization-invite-role-default` | 3 of 6 cases through `arrange()`; default 5000 ms; `①` paid the cold start | **included** | | `auth-login-register-envelope` | same shape | already fixed by PR objectstack-ai#20272 (`80c29a14`); not edited | | `client.hono`, `client.batch-transaction`, `client.data-prefix`, `client.environment-scoping`, `client.metadata-prefix` | a `LiteKernel` + ObjectQL + `SqliteWasmDriver` REST/Hono server booted ONCE per file (per describe in the two `*-prefix` files) as a shared fixture in a `beforeAll` with an explicit `30_000` hookTimeout; no `AuthManager`, no sign-up | **excluded**: the claim's file surface is suites that boot inside their first clocked `it`, and a hook-booted shared fixture is a different shape (the boot IS the fixture). Reach: in the red CI run all five passed; under 24 busy loops on this box all five passed (1 run). Their hook duration is NOT MEASURED: vitest's JSON file span does not include it. Noted, not filed. | | `meta-delete-item-carriers` | a `SqliteWasmDriver` engine + the real protocol + `RestServer` booted inside `it`, but only from the 14th case on (Part 2); its first 13 cases are mock-fetch; an explicit `60_000` on all 7 engine cases; no `AuthManager`, no sign-up | **excluded**: its first clocked `it` is a mock case, so it is outside the claim's file surface as written. Its first engine case is not where a cold start sits heavily: 137 ms idle against 38-194 ms for the later engine cases, and 810 ms against 312-1204 ms under 24 busy loops. Its `60_000`s widen windows that measured under 1.3 s. Noted, not filed. | | `i18n-wire-dialect` | a `LiteKernel` + `HonoServerPlugin` in a `beforeAll`, no driver, no auth | **excluded**: boots no engine | | the other 38 | mock `fetch`, source-text reads, or type-level assertions; `client.test.ts`'s sign-up cases are fetch-level; `oauth-applications-*` and `organization-get-active-member-addressing` drive doubles | **excluded**: no engine | Count: 4 included, 1 already fixed, 45 excluded. ## What changed Per file, the same three moves as PR objectstack-ai#20272: - A module-scope warm-up that runs the file's OWN arrangement, not a list of loads: - `auth-get-session-envelope`: `await signedIn(); await closeEngines();` - `auth-rotated-session-token`: `await signedIn(); await closeEngines();` - the two organization suites: `process.env.OS_TENANCY_POSTURE = 'isolated';` then `await arrange().finally(restorePosture);`. `arrange()` needs the posture the file's `beforeAll` sets for its cases, and the module scope runs before any hook, so the warm-up holds the posture itself and `.finally` puts back what it found, even if it throws. - The teardown the warm-up reuses is extracted without changing it: `closeEngines` is the old `afterEach` loop byte for byte (in `auth-rotated-session-token` the `afterEach` keeps its `vi.restoreAllMocks()` and calls `closeEngines`); `restorePosture` is the old `afterAll` body byte for byte. - A placement pin per file (`⑥`, or `④` in `auth-rotated-session-token`), read off the file's own text: - exactly one column-0 warm-up line, so it sits in no function body; - in `auth-get-session-envelope`, no `before*` hook at all (as in PR objectstack-ai#20272's `⑦`); - in the three files that keep a legitimate hook (the console-spy `beforeEach`, the posture `beforeAll`), exactly one hook, and no hook body calls the arrangement. A hook indented inside a `describe` is read on to that block's column-0 close, so it cannot hide one; - in `auth-rotated-session-token`, also no `}, N);` per-case timeout, so the widened posture cannot come back. - A header section per file with that file's own readings. No assertion in an existing case changed. The warm-up shares nothing a case asserts on: every case still builds a fresh engine, a fresh `AuthManager` and a fresh sign-up. What it leaves warm is process-level (the module registry, sql.js's compiled WASM, the JIT), which the first case used to leave to every later case. In the two organization suites the warm-up's engine stays open, as every case's does there (`arrange()` hands none back and those files close none), so no case runs in a state no case ran in before. Why module scope and not a hook or a timeout: `@vitest/runner@4.1.11`, as installed here, wraps hooks and test bodies in `withTimeout(...)` (`dist/chunk-artifact.js:672`, `:724`, `:1787`) and awaits `runner.importFile(filepath, "collect")` bare (`:2457`). The repo's rule is "clocked windows measure behaviour, never loading" (AGENTS.md, Build & Test; `check:test-source-alias`). ## Before / after, at CI's own 5000 ms budget Idle (4 vCPU, the box otherwise quiet), first case against the later cases of the same file: | suite | base `c74de10a9` | fix `49bd6fdfa` | |:---|---:|---:| | `auth-get-session-envelope` | 1035 vs 110-330 ms | 316 vs 90-277 ms | | `auth-rotated-session-token` | 1713 vs 302-665 ms | 809 vs 278-581 ms | | `organization-invitation-resend-team-placement` | 1245 vs 307-411 ms | 332 vs 284-399 ms | | `organization-invite-role-default` | 968 vs 310-336 ms | 382 vs 329-340 ms | Under load, PR objectstack-ai#20272's method: CPU-bound `node -e 'for(;;){}'` loops on 4 vCPU, PIDs recorded and killed by trap. One vitest run per leg over the four files, `--maxWorkers=2`. On the base tree, 24 loops gave CI's exact signature, `Test timed out in 5000ms` on the first case, in all three default-budget suites (1 run); `auth-rotated-session-token`'s first case took 10314 ms there, green only because of its `60_000`. Measured as interleaved pairs: an ABLATED leg (the four warm-ups deleted from the committed files) and a FIX leg (the committed files), same command, same load, order alternated between pairs. The box is shared, so interleaving puts its drift on both sides. ``` ./node_modules/.bin/vitest run --maxWorkers=2 --reporter=verbose --reporter=json --outputFile.json=RUN.json FOUR_FILES ``` | 24 busy loops, N = 4 pairs | ablated: first-case timeouts | fix: first-case timeouts | fix: first case | fix: heaviest other case | |:---|---:|---:|---:|---:| | `auth-get-session-envelope` | 4 / 4 | **0 / 4** | 1969-2536 ms | 1979-2751 ms | | `auth-rotated-session-token` | 4 / 4 | **4 / 4** (see below) | 5034-5319 ms | 3440-4771 ms | | `organization-invitation-resend-team-placement` | 4 / 4 | **0 / 4** | 1577-3095 ms | 2523-3086 ms | | `organization-invite-role-default` | 4 / 4 | **0 / 4** | 2359-2575 ms | 2056-2271 ms | | 16 busy loops, N = 2 pairs | ablated: first-case timeouts | fix: first-case timeouts | fix: first case | |:---|---:|---:|---:| | `auth-get-session-envelope` | 1 / 2 (4816, 5134 ms) | 0 / 2 | 1320-1523 ms | | `auth-rotated-session-token` | 2 / 2 (5053, 5229 ms) | 0 / 2 | 4125-4185 ms | | `organization-invitation-resend-team-placement` | 1 / 2 (4752, 5113 ms) | 0 / 2 | 1853-1904 ms | | `organization-invite-role-default` | 0 / 2 (4663, 4903 ms) | 0 / 2 | 1429-1611 ms | Every ablated leg also reddened all four placement pins (4 of 4, in all 6 ablated legs). Every fix leg's pins passed. In one ablated leg, `organization-invitation-resend-team-placement`'s second case also timed out (5121 ms): vitest does not cancel a timed-out body, so the first case's orphaned body was still running beside it. No fix leg showed that. ## `auth-rotated-session-token`: the `60_000`s are gone, and its first case is heavy by its own work Triage note 3 and the dispatch both rule out a raised timeout, so all seven `60_000`s are removed. With the cold start moved out, six of the seven cases fit the default budget at both loads. The first case (`①`) does not fit at 24 busy loops, and that is its own behaviour, not loading: - `①` is the card's whole probe: a sign-up plus five more calls that each check a password or a TOTP code (login, enable, verifyTotp, disable, deleteUser). - A throwaway variant whose module-scope warm-up ran that WHOLE probe left `①` no faster: 637-716 ms against 690-709 ms for the committed warm-up (3 runs each, idle, the file alone). So no loading is left in its window. | basis | `①` | margin to 5000 ms | |:---|---:|---:| | idle, fix | 690-809 ms | 6.2-7.2x | | the red CI run's own slowdown on warm cases (`auth-get-session-envelope`'s same-work cases ran 2.9-5.9x their idle time) | about 2.0-4.8 s (inferred, not measured) | about 1.0-2.5x | | 16 busy loops, fix (2 runs) | 4125-4185 ms | 1.2x | | 24 busy loops, fix (4 runs) | 5034-5319 ms | reached, 4 of 4 | Before this PR the same case held a 10.3 s window at 24 busy loops (cold start plus this work) under its `60_000`. The file's header records this residual and names the lever: the case's own work, not a timeout. The open question in the report asks the seat whether to accept it as is. ## Ablation (fix committed first, restore proven) The fix was committed (`49bd6fdfa`) before any mutation. Each ablated leg nested four `node scripts/ablation-replace.mjs --file ABS_PATH --anchor ANCHOR --delete -- ...` calls (WRAP mode, restore armed on EXIT, INT and TERM), one per file: - `auth-get-session-envelope`, `auth-rotated-session-token`: the anchor `await signedIn();` + newline + `await closeEngines();` + newline; - the two organization suites: the posture line + newline + `await arrange().finally(restorePosture);` + newline. In every leg the tool reported, per file, anchor x1 to x0, a changed blob, and "ok mutation landed" (24 of 24), and the leg printed `warm-up lines=0 (of 4 files)` read off the disk before vitest started. After every leg the tool proved each restore by blob hash against `HEAD` and an empty `git diff HEAD` (24 of 24), and a second check after each leg read `4/4 blob == HEAD blob, git diff HEAD = 0 bytes` (12 of 12 legs). The files run from source, so no `dist/` preflight applies. ## Verification All at `220735eb3`: the fix `49bd6fdfa`, two commits that only edit header comments, and a merge of `origin/main` at `d498113b5`. The branch delta against it is exactly the four test files, +321 / -23. Every exit code was captured before any pipe. The union below was first run at `5a9a01d00` and then run again in full at `220735eb3`, after the last commit corrected a count in a comment. Both runs gave the same verdicts. - After the merge: `pnpm install --frozen-lockfile`, then the dependency closure `pnpm turbo run build --filter='@objectstack/client...' --concurrency=2`, 33 of 33 tasks. - `pnpm --filter @objectstack/client exec vitest run --maxWorkers=2`: `Test Files 50 passed (50)`, `Tests 641 passed (641)` (637 before, plus the 4 pins), exit 0. - `pnpm --filter @objectstack/client typecheck`: exit 0, `check:test-typecheck: OK`, 0 files / 0 errors. `tsc -p tsconfig.test.json --listFilesOnly` compiles all four files (4 hits), so that green covers them. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_LIST` (every line with its recorded exit code): `53 derived famil(ies) accounted for — 51 run, 2 NOT-MEASURED`, 0 UNRUN, and each of the 51 run exited 0. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Both exited 3 (PREREQUISITE NOT MET): they need every package's `dist/`, the whole `./packages/*` build. A declared narrowing; CI runs both. This diff is four test files, which no build emits (the `dist/` check is under Changeset), and `@objectstack/client`'s test-layer debt ledger is empty and re-measured just above at 0 errors. - `check:skill-examples` exited 3 until its prerequisite was met: first `packages/client-react/dist` was absent, then `packages/client/dist` was older than an edited `src/` file. After `pnpm turbo run build --filter=@objectstack/client-react` and `pnpm --filter @objectstack/client build`, the same command exited 0: 259 prose examples across 3 surfaces. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0. It judged 0 files: test files are on its deferred list. - `pnpm lint` (`eslint . --no-inline-config`, the whole repo, not a narrowing): exit 0. - Control-byte self-scan of the four files: no hits (`check:nul-bytes` also exit 0). ## Changeset `skip-changeset`. The diff is four `*.test.ts` files. `@objectstack/client`'s `files[]` is `dist`, `README.md`, `CHANGELOG.md`. After `pnpm --filter @objectstack/client build` at `220735eb3`, strings unique to these test files (`closeEngines`, `restorePosture`, `cold start stays outside`, `OS_TENANCY_POSTURE`) have 0 hits under `packages/client/dist/`, while the positive control `ObjectStackClient` hits all 4 emitted artifacts (`index.js`, `index.mjs`, `index.d.ts`, `index.d.mts`). No published byte changes. ## Acceptance notes - **`auth-rotated-session-token.test.ts` `①` residual** (section above): at 24 busy loops it reaches the 5000 ms budget on its own work, 4 of 4 runs; at 16 loops it fits with 1.2x. CI has not run it on the default budget before this PR. If CI reds it, the lever is that case's own work (for example splitting the probe, which would change the card's own probe and needs a ruling), not a timeout. Its second-heaviest cases (`changePassword` with `revokeOtherSessions`, and `twoFactor.disable`) reached 4.7-4.8 s at 24 loops. - **`meta-delete-item-carriers.test.ts`** carries an explicit `60_000` on all 7 of its engine cases, the posture the clocked-window rule warns relocates a cliff. Measured, those windows are small (under 1.3 s at 24 busy loops) and its first engine case holds little one-time cost, so it is no timeout risk today. Outside this card's surface. Noted, not filed. - **The five hook-booted `client.*` suites** boot a shared server in a `beforeAll` with an explicit `30_000` hookTimeout. They passed in the red CI run and under 24 busy loops here. Their hook time is not measured. Outside this card's surface. Noted, not filed. - The two organization suites never close their engines, before or after this PR (`arrange()` hands none back). The warm-up adds one more open in-memory engine per file, as each of their cases does. Noted, not filed. - The measurement box was shared: the verify lock serialises locked runs only, and other worktrees' work ran beside some legs (the one-minute load average read 2.1-26 at pair starts). The interleaved pairs, with the order alternated, are what keep the before/after comparison fair under that drift. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20116
Clause-②: no (narrowing)
BREAKING (an accept-set narrowing at the save doors; the changeset carries the ADR-0087 disposition
registered filter-comparand-types-and-widget-nested-slots-refused-at-save).Stage 2 of the save-door ↔ query-face parity collector: the two open members of the seat's release
5857575995. With the report half folded in (seat answer5859432781, after PR #20238 landed as6a6a17b6), both members are done, and so is the collector. The objectui producer stage 1 found is carried by objectui#10790, not by this card.FilterConditionSchemanow asks the comparand-TYPE face (normalizeFilterComparandTypes) read-only, after the comparand-shape face, inside the one judge stage 1 built (reportQueryFaceRefusals,packages/spec/src/data/filter-save-door-refusals.ts). A plain object where a single value belongs, aMap, a class instance, a function, a Symbol,undefinedand a bigint beyond ±2^53 are refused on save — as the comparand, an implicit-equality comparand or a list member — at every reach the save doors have, and nothing the face passes is refused.DashboardWidgetSchema.filter,ReportSchema.runtimeFilterandJoinedReportBlockSchema.runtimeFilterdeclare the analytics-carrier filter the two dataset carriers declare, so each judges the slots INSIDE a nested relation the way the analyticswheredoor does. fix(spec)!: a dataset or measure filter with a list inside a nested relation is refused on save (#20080) #20207's refinement was inline and module-private indataset.zod.ts; it moved verbatim intopackages/spec/src/ui/analytics-carrier-filter.ts(not in theuibarrel), byte-neutral forDataset(measured below). Inreport.zod.tsonly the tworuntimeFilterlines (and the import) change.Zone 2, measured
1. Re-probe (base
origin/main17bd3187; spec doors fromsrc, the analytics door fromservice-analyticssrcover a fresh specdist){ stage: { $eq: { a: 1 } } }{ stage: { $in: [{ a: 1 }] } }{ stage: { $eq: Map } }{ stage: Map }{ acct: … }{ acct: { stage: { $in: ['won', null] } } }{ acct: { region: ['a'] } },{ acct: { region: { $eq: ['a'] } } }(#20080)$gt: { $field },$gt: Date,'{current_user_id}',{ acct: { region: 'NA' } }After (src, same probe): every M-type cell is refused at the top level on all six carriers, at the slot (
stage.$eq) or the member (stage.$in.0); every nested M-type and M-widget cell is refused on all five analytics carriers (widgets.0.filter.acct.stage.$in.1,runtimeFilter.acct.stage.$in.1,blocks.0.runtimeFilter.acct.stage.$in.1); bareFilterConditionSchemastill accepts the nested cells (the face's reach); every control is accepted.2. One judge
The type face is asked inside
reportQueryFaceRefusals, the function both walks call, exactly where stage 1 asks the shape face — so it reaches the shared walk (checkFilterConditionComparands) and the analytics carriers' nested walk at once, with no second walk. The judge raises ONE issue per slot, the first the query doors give in their order (shape face → type face → flag rule;parseFilterAST, the engine seam andnormalizeWhereComparandsall run them in that order). At the top level and in the combinators, where a face refuses a slot, the shared walk's own#19514$icontainsand#8793preset arms stay silent on that slot. Inside a nested relation on an analytics carrier they do not: those two arms still judge nested slots beside the carrier walk's faces, so a nested$icontainswith a type-refused comparand, or a nested one-bound$betweenof a preset name, carries two issues (the review measured 303 such cells new at the head). No verdict moves either way; the changeset and the entry say exactly this.What the type face "judges only at request time" was measured: nothing. The face is context-free (
contextis a message prefix only). The request-time values are{ $field }references (the face steps around them),Dates (accepted), and{placeholder}strings such as{current_user_id}/{today}— strings at save time, resolved byresolveWhereTokensonly AFTER both faces have run on the engine seam. Each is pinned accepted-and-kept (§4 controls), plus a bigint within 2^53, which the save door keeps as written (the face would narrow it on a query).Two classifications follow the type face rather than restating it: a field value is a comparand unless it is a PLAIN object (prototype
Object.prototype/null), so{ stage: new Map() }reaches the face instead of being walked as an empty nested relation; and the whole field entry is shown to the face first, so a spec it classifies as a{ $field }reference is stepped around whole, as the face does (pinned as a control).3. The dashboard and report carriers — extraction, byte-neutral
dfa424f6), verbatim;DatasetSchema/DatasetMeasureSchemacallanalyticsCarrierFilter()as before.z.toJSONSchemaofDatasetSchema,DatasetMeasureSchema,DashboardSchemaandDashboardWidgetSchema: sha256246850f2b9efdf1b…at base17bd3187, at the extraction commit, and after the widget carrier — byte-identical. WithReportSchemaandJoinedReportBlockSchemaadded, the six projections hash35ba34f964bb8fd6…both before the report fold (6a0cfb05) and after it. The parse probe is identical base vs extraction.dropped-refinements.baseline.json: theui/Datasetandui/DatasetMeasurerows are unchanged. The merge of6a6a17b6conflicted only in this ledger's measured header; main's side was taken and the branch's sites re-added from build-schemas' own printed "corrected entries" (no hand-picked site):ui/DashboardWidgetfilter,ui/Dashboardwidgets.element.filter,ui/ReportruntimeFilterandblocks.element.runtimeFilter,ui/JoinedReportBlockruntimeFilter, and the same three positions in the four installed-package envelopes — +17 sites, 0 removed,measured.droppedRefinementSites605 → 622.FilterConditionSchema.optional()'s JSON Schema, and that the widget filter and the reportruntimeFilterkeep their published descriptions.4. The enumerating pin
filter-save-door-face-parity.test.ts, extended, not duplicated:queryFacesRefusenow asks all three rules. The operator arms still derive fromFieldOperatorsSchema's keys, and a new assertion requires the type face to judge EVERY declared operator over the battery (its own test reconciles its scalar/list split against the same vocabulary). The battery gained the type face's shapes and neighbours (Map, class instance, function, Symbol,{ $field: 5 },{}, bigints within and beyond 2^53, lists holding a plain object / Map /undefined/ big bigint, a plain-object$betweenbound, bigint pairs).filter, measurefilter, dashboard widgetfilter, reportruntimeFilter, joined blockruntimeFilter(the two report rows wereEXPECTED_OPENuntil the fold and now sit inCARRIERS), plus a pin that the carrier list is exactly those five.FILTER_COMPARAND_TYPE_CASES): everydoor-refusalrow is refused on save; everymatches/compilesrow is accepted AND kept as written.5. Producer census (narrowing), with lit controls — 0 hits
undefinedunder an operatornew Xunder an operatorexamples/**@eaf7a925$inlists 3)packages/**@eaf7a925caselabels / the type face's own table (control:$fieldin a scalar slot 67)$fieldmembers /{placeholder}strings (control 238)null83)new Date/ the tablef8a9d0fb05main@96eb092fbfPlus a runtime walk of every value under a
filter/runtimeFilter/where/having/relatedListFilterkey in the loaded example stacks, old door vs new door on each:app-crm8,app-todo15,app-multi-package0,app-showcase20 (its metadata modules; its config needs connector builds) — 0 refused by the new door alone. Lit control: a planted{ stage: { $eq: { a: 1 } } }and a planted nested$innull member fire the detector in every run. No ADR-0087 D2 conversion: nothing to convert.The words (changed or new refusal text)
A type-face cell reads the face's own sentence less its
at where.SLOTclause — nothing restated:at
filter.stage.$eq, or at the member (filter.stage.$in.1).undefinedgets the face's own sentence (Filter comparand is undefined. { key: undefined } cannot be told apart from an omitted key, … Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the key. …), a bigint beyond 2^53 its (Filter comparand is the bigint …n, whose magnitude exceeds 2^53 — …). The clause removed is the one the face was handed (whereplus this slot), so nothing is parsed out of the text; if the face ever spells its location differently, the whole message is reported location included, and §2's "noat where." pin goes red.Nested cells on the widget and report carriers print the same sentence as their top-level form (stage 1 and #20207's rule): e.g.
widgets.0.filter.acct.stage.$in.1andruntimeFilter.acct.stage.$in.1carry the enforced$inslot's null-member sentence.Behaviour changes, each pinned
FilterConditioncarrierruntimeFilters$icontains/ preset arms silent on a face-refused slot there (not inside a relation on an analytics carrier) — a dedupe, no verdict moves; stated at that reach in the changesetPOST /analytics/dataset/queryselection.runtimeFilterandPOST /analytics/querywherewith{ stage: { $eq: { a: 1 } } }/{ stage: { $in: ['won', { a: 1 }] } }:400 INVALID_FILTER→400 VALIDATION_FAILEDlocated on the memberpackages/rest/src/analytics-filter-refusal-envelope.test.tsAT_THE_DOORrows + the sibling-schema controlPin sweep
① Every refusal code and message this touches was grepped repo-wide. The type face's text is unchanged (only called). The HTTP-door code move has two routes and both are pinned in the rest file above; no rest / runtime test sent a type-face cell through a schema door before (grep over
packages/**tests outside spec: the plain-object / Map /undefined/ bigint comparand hits live in the analytics door's, the drivers', objectql's engine and read-scope suites, which call the faces directly, not a schema). Spec pins whose words could move — a flag with an object /undefinedcomparand,$icontainswith a type-refused comparand, a preset endpoint on a malformed$between— have no existing pin. ② The flipped rest rows assert the substance: status 400,VALIDATION_FAILED, exactly onedetails.fields[]entry at the member, the sentence, and noat where..Tests
Final head
830a071a(mergesorigin/maindfd8e398, then corrects the dedupe sentence of the changeset and the semantic entry — text only), everything throughscripts/pm/os-verify-lock.sh,VERDICT command-exit 0:@objectstack/specat830a071a: build 0;check:generated0 ("All 15 generated artifacts are up to date");typecheck0; full suite 587 files / 17031 passed / 1 todo; the parity pin alone 153 passed.check-adr-0087-registration0 ([BREAKING+bang+clause-②-narrowing] registered filter-comparand-types-and-widget-nested-slots-refused-at-save) andcheck-changeset-no-major0 at830a071a.e9f93902(review head): spec full suite 585 files / 16988 passed / 1 todo.3d9621a8(the fold plus the ledger, before the secondmainmerge, which touches no file of this diff's behaviour): spec 585 / 16955 passed; consumer closures built (exit 0);service-analytics129 files / 3041 passed;lint111 / 4297;rest(--project local) 202 / 3664 passed / 1 skipped.resttypecheck 0 at54b99f3c(the rest file is unchanged since). The two new rest rows ran by name (a plain object where a single value belongs → 400 VALIDATION_FAILED, located on the member,a plain object as an $in member → …).scripts/ablation-replace.mjs(WRAP), each anchor x1 → x0 on disk with the blob changed, each restore proven blob == HEAD blob andgit diff HEADempty (script trap restores on EXIT/INT/TERM). Parity pin:3d9621a8, 153 tests — report-carrier arm:ReportSchema.runtimeFiltercarrier stripped (analyticsCarrierFilter().unwrap().optional()) → 18 failed;JoinedReportBlockSchema.runtimeFilterstripped → 18 failed; restored → 153 passed (unwrapmarkers on disk after restore: 0);54b99f3c, 152 tests — type face off → 48 failed; widget carrier off → 18; shared walk's Map / class-instance classification off → 6; nested walk's classification off → 3; one-issue-per-slotcontinueoff → 1 (direction: MORE diagnostics — two issues atname.$icontains); restored → 152 passed.src.830a071a:dispatch-gates --commandsderived 90 on the actual paths (the 89 of the review head plusnode scripts/check-issue-citations.mjs, which main's7338efe0now runs locally); 88 exit 0 —check:doc-formula-expressionsandcheck:lean-entry-closurefirst answered exit 3 (theirformula/objectqlbuilds were absent in the re-created worktree) and exit 0 after that closure was built under the lock;check:dual-build-cjs-loadsandcheck:type-check-debtexit 3 (PREREQUISITE NOT MET: whole-repo build) = NOT MEASURED;--ranwith recorded codes: 90 accounted, 88 run, 2 NOT MEASURED.830a071a:eslint --no-inline-config --format jsonover the 10 changed.tsfiles (count read from the JSON) → 0 errors, 0 warnings. The population iseslint.config.mjs, which "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so the diff cannot move a verdict on an untouched file. Repo-widepnpm lintis CI's.mainmoved after830a071atoo (2 commits at the gate derivation, none touching what this answer derives from, asdispatch-gatesread it).objectql,metadata-protocol,runtimeand example suites (no fixture carries a refused shape through a schema door by the sweep above; CI runs them).Hand-written docs the drift check named (github-actions comment
5860213966)Each re-read against this PR's behaviour — the type-face refusals at save, the widget / report
runtimeFilternested-relation refusals, and the analytics routes' code move for the type face's JSON cells:content/docs/api/data-api.mdxINVALID_FILTERsentences are about the data routes'?filter(the engine's normalizer, not aFilterConditionSchemaparse), and the/analytics/querysection says only thatwhereis theFilterConditionfind()accepts — still true; no sentence names the code a type-face cell gets.content/docs/api/error-catalog.mdxINVALID_FILTER/VALIDATION_FAILEDare defined generically; no sentence claims the analytics routes answerINVALID_FILTERfor a plain-object comparand.content/docs/data-modeling/analytics.mdxfilterand reportruntimeFilterexamples ($ninlist,{current_quarter_start}placeholder) are accepted by the new doors; the placeholder paragraph and the "one author-facing shape" section stay true; nothing says a nested-relation filter saves on those carriers.content/docs/protocol/objectql/query-syntax.mdxFilterConditionSchema/ field-reference / relation-traversal text stays true:{ $field: 'col' }is still accepted, and the page makes no claim about a plain-object,Maporundefinedcomparand passing validation.No release page (
content/docs/releases/**) names these shapes; none was touched.Acceptance notes
DatasetSelectionSchema.runtimeFilter/AnalyticsQueryRequestSchema.wherecarry the shared reach, so a top-level refused slot answersVALIDATION_FAILEDat the schema door while the same slot inside a relation answersINVALID_FILTERfrom the analytics normalizer. Both 400, both located; not a save door, so not this collector's. Noted, not filed (carrier: none).5859432781):report.zod.ts(the tworuntimeFiltercarriers only); and, accepted as the order's own mechanism,data/filter-save-door-refusals.ts(stage 1's judge),ui/analytics-carrier-filter.ts+ui/dataset.zod.ts(Zone 2.3's extraction),packages/rest/src/analytics-filter-refusal-envelope.test.ts(the HTTP-door pin).GlobalFilterOptionsFromSchema.filter(a dashboard's options source) is an engine query, not charted through the analytics door, so it keeps the shared reach.Generated by Claude Code