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 |
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