Skip to content

fix(spec)!: a comparand the comparand-type face refuses is refused on save, and every charted presentation filter judges its nested relations (#20116) - #20325

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-20116-stage-2-type-and-widget
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-20116-stage-2-type-and-widget

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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 answer 5859432781, after PR #20238 landed as 6a6a17b6), both members are done, and so is the collector. The objectui producer stage 1 found is carried by objectui#10790, not by this card.

  • M-type — done. FilterConditionSchema now 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, a Map, a class instance, a function, a Symbol, undefined and 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.
  • M-widget — done. DashboardWidgetSchema.filter, ReportSchema.runtimeFilter and JoinedReportBlockSchema.runtimeFilter declare the analytics-carrier filter the two dataset carriers declare, so each judges the slots INSIDE a nested relation the way the analytics where door 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 in dataset.zod.ts; it moved verbatim into packages/spec/src/ui/analytics-carrier-filter.ts (not in the ui barrel), byte-neutral for Dataset (measured below). In report.zod.ts only the two runtimeFilter lines (and the import) change.

Zone 2, measured

1. Re-probe (base origin/main 17bd3187; spec doors from src, the analytics door from service-analytics src over a fresh spec dist)

member FilterCondition Dataset.filter Measure.filter Widget.filter Report.runtimeFilter JoinedBlock.runtimeFilter type face (top) analytics door
{ stage: { $eq: { a: 1 } } } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT refuse 400 refuse 400
{ stage: { $in: [{ a: 1 }] } } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT refuse 400 refuse 400
{ stage: { $eq: Map } } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT refuse 400 refuse 400
{ stage: Map } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT refuse 400 refuse 400
each of the four under { acct: … } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT accept (not descended) refuse 400
{ acct: { stage: { $in: ['won', null] } } } ACCEPT refuse refuse ACCEPT ACCEPT ACCEPT accept refuse 400
{ acct: { region: ['a'] } }, { acct: { region: { $eq: ['a'] } } } (#20080) ACCEPT refuse refuse ACCEPT ACCEPT ACCEPT accept refuse 400
controls: $gt: { $field }, $gt: Date, '{current_user_id}', { acct: { region: 'NA' } } ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT ACCEPT accept accept

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); bare FilterConditionSchema still 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 and normalizeWhereComparands all 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 $icontains and #8793 preset 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 $icontains with a type-refused comparand, or a nested one-bound $between of 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 (context is 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 by resolveWhereTokens only 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

  • The move is its own commit (dfa424f6), verbatim; DatasetSchema / DatasetMeasureSchema call analyticsCarrierFilter() as before.
  • z.toJSONSchema of DatasetSchema, DatasetMeasureSchema, DashboardSchema and DashboardWidgetSchema: sha256 246850f2b9efdf1b… at base 17bd3187, at the extraction commit, and after the widget carrier — byte-identical. With ReportSchema and JoinedReportBlockSchema added, the six projections hash 35ba34f964bb8fd6… both before the report fold (6a0cfb05) and after it. The parse probe is identical base vs extraction.
  • dropped-refinements.baseline.json: the ui/Dataset and ui/DatasetMeasure rows are unchanged. The merge of 6a6a17b6 conflicted 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/DashboardWidget filter, ui/Dashboard widgets.element.filter, ui/Report runtimeFilter and blocks.element.runtimeFilter, ui/JoinedReportBlock runtimeFilter, and the same three positions in the four installed-package envelopes — +17 sites, 0 removed, measured.droppedRefinementSites 605 → 622.
  • Pin: §7 of the parity test asserts the carrier projects to exactly FilterConditionSchema.optional()'s JSON Schema, and that the widget filter and the report runtimeFilter keep their published descriptions.

4. The enumerating pin

filter-save-door-face-parity.test.ts, extended, not duplicated:

  • §1 queryFacesRefuse now asks all three rules. The operator arms still derive from FieldOperatorsSchema'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 $between bound, bigint pairs).
  • §5 runs the operator × comparand table and the implicit slot, one and two hops down, on every analytics carrier — dataset filter, measure filter, dashboard widget filter, report runtimeFilter, joined block runtimeFilter (the two report rows were EXPECTED_OPEN until the fold and now sit in CARRIERS), plus a pin that the carrier list is exactly those five.
  • §6 walks the type face's own conformance table (FILTER_COMPARAND_TYPE_CASES): every door-refusal row is refused on save; every matches / compiles row is accepted AND kept as written.

5. Producer census (narrowing), with lit controls — 0 hits

corpus object in a scalar slot object list member bigint literal undefined under an operator new X under an operator nested relation holding a list / operator map in a filter
objectstack examples/** @ eaf7a925 0 0 (control: $in lists 3) 0 0 0 0 (control: filters with an operator-map first entry 14)
objectstack non-test packages/** @ eaf7a925 25 raw, all prose / driver case labels / the type face's own table (control: $field in a scalar slot 67) 26 raw, all prose / $field members / {placeholder} strings (control 238) 3, prose 31, prose (control: null 83) 6, new Date / the table 0 (control 162)
objectui @ pin f8a9d0fb05 0 (control 1) 0 (control 12) 0 1, a comment 1, a refusal message 0 (control 13)
cloud main @ 96eb092fbf 0 (control 2) 1, a comment (control 11) 0 0 0 0 (control 14)

Plus a runtime walk of every value under a filter / runtimeFilter / where / having / relatedListFilter key in the loaded example stacks, old door vs new door on each: app-crm 8, app-todo 15, app-multi-package 0, app-showcase 20 (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 $in null 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.SLOT clause — nothing restated:

Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: the backends disagreed on this input (crash / zero rows / silently edited query). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

at filter.stage.$eq, or at the member (filter.stage.$in.1). undefined gets 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 (where plus 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 "no at 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.1 and runtimeFilter.acct.stage.$in.1 carry the enforced $in slot's null-member sentence.

Behaviour changes, each pinned

change pin
type-face cells refused on save, top level + combinators, every FilterCondition carrier parity §1 (all positions), §3 (six carriers), §6
type-face cells + stage-1 cells + #20080 lists refused INSIDE a relation on the widget filter and both report runtimeFilters parity §5 (all five carriers)
one issue per slot at the top level and in the combinators, shape → type → flag; the $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 changeset parity §6 "one slot, one issue" (top level)
POST /analytics/dataset/query selection.runtimeFilter and POST /analytics/query where with { stage: { $eq: { a: 1 } } } / { stage: { $in: ['won', { a: 1 }] } }: 400 INVALID_FILTER → 400 VALIDATION_FAILED located on the member packages/rest/src/analytics-filter-refusal-envelope.test.ts AT_THE_DOOR rows + the sibling-schema control
request-time values accepted and kept parity §4

Pin 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 / undefined comparand, $icontains with 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 one details.fields[] entry at the member, the sentence, and no at where..

Tests

Final head 830a071a (merges origin/main dfd8e398, then corrects the dedupe sentence of the changeset and the semantic entry — text only), everything through scripts/pm/os-verify-lock.sh, VERDICT command-exit 0:

  • @objectstack/spec at 830a071a: build 0; check:generated 0 ("All 15 generated artifacts are up to date"); typecheck 0; full suite 587 files / 17031 passed / 1 todo; the parity pin alone 153 passed. check-adr-0087-registration 0 ([BREAKING+bang+clause-②-narrowing] registered filter-comparand-types-and-widget-nested-slots-refused-at-save) and check-changeset-no-major 0 at 830a071a.
  • At e9f93902 (review head): spec full suite 585 files / 16988 passed / 1 todo.
  • At 3d9621a8 (the fold plus the ledger, before the second main merge, which touches no file of this diff's behaviour): spec 585 / 16955 passed; consumer closures built (exit 0); service-analytics 129 files / 3041 passed; lint 111 / 4297; rest (--project local) 202 / 3664 passed / 1 skipped. rest typecheck 0 at 54b99f3c (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 → …).
  • Ablation, through scripts/ablation-replace.mjs (WRAP), each anchor x1 → x0 on disk with the blob changed, each restore proven blob == HEAD blob and git diff HEAD empty (script trap restores on EXIT/INT/TERM). Parity pin:
    • at 3d9621a8, 153 tests — report-carrier arm: ReportSchema.runtimeFilter carrier stripped (analyticsCarrierFilter().unwrap().optional()) → 18 failed; JoinedReportBlockSchema.runtimeFilter stripped → 18 failed; restored → 153 passed (unwrap markers on disk after restore: 0);
    • at 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-slot continue off → 1 (direction: MORE diagnostics — two issues at name.$icontains); restored → 152 passed.
    • No leg needs a build: the pin imports spec src.
  • Gates at 830a071a: dispatch-gates --commands derived 90 on the actual paths (the 89 of the review head plus node scripts/check-issue-citations.mjs, which main's 7338efe0 now runs locally); 88 exit 0 — check:doc-formula-expressions and check:lean-entry-closure first answered exit 3 (their formula / objectql builds were absent in the re-created worktree) and exit 0 after that closure was built under the lock; check:dual-build-cjs-loads and check:type-check-debt exit 3 (PREREQUISITE NOT MET: whole-repo build) = NOT MEASURED; --ran with recorded codes: 90 accounted, 88 run, 2 NOT MEASURED.
  • ESLint, narrowed and proven, at 830a071a: eslint --no-inline-config --format json over the 10 changed .ts files (count read from the JSON) → 0 errors, 0 warnings. The population is eslint.config.mjs, which "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so the diff cannot move a verdict on an untouched file. Repo-wide pnpm lint is CI's.
  • main moved after 830a071a too (2 commits at the gate derivation, none touching what this answer derives from, as dispatch-gates read it).
  • NOT MEASURED: objectql, metadata-protocol, runtime and 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 runtimeFilter nested-relation refusals, and the analytics routes' code move for the type face's JSON cells:

page verdict why
content/docs/api/data-api.mdx unchanged Its INVALID_FILTER sentences are about the data routes' ?filter (the engine's normalizer, not a FilterConditionSchema parse), and the /analytics/query section says only that where is the FilterCondition find() accepts — still true; no sentence names the code a type-face cell gets.
content/docs/api/error-catalog.mdx unchanged INVALID_FILTER / VALIDATION_FAILED are defined generically; no sentence claims the analytics routes answer INVALID_FILTER for a plain-object comparand.
content/docs/data-modeling/analytics.mdx unchanged The widget filter and report runtimeFilter examples ($nin list, {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.mdx unchanged Its FilterConditionSchema / field-reference / relation-traversal text stays true: { $field: 'col' } is still accepted, and the page makes no claim about a plain-object, Map or undefined comparand passing validation.

No release page (content/docs/releases/**) names these shapes; none was touched.

Acceptance notes

  • Request doors keep two codes for one family. DatasetSelectionSchema.runtimeFilter / AnalyticsQueryRequestSchema.where carry the shared reach, so a top-level refused slot answers VALIDATION_FAILED at the schema door while the same slot inside a relation answers INVALID_FILTER from the analytics normalizer. Both 400, both located; not a save door, so not this collector's. Noted, not filed (carrier: none).
  • File surface, as amended by the seat (5859432781): report.zod.ts (the two runtimeFilter carriers 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

…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>
…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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 17 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in comparandTypeFaceRefusal))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in comparandTypeFaceRefusal))
  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const), DatasetMeasureSchema (symbol, a top-level const), ReportSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in comparandTypeFaceRefusal))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in comparandTypeFaceRefusal))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in comparandTypeFaceRefusal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dfd8e398aacfa74b8f401a9186814fec093cf010 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d462665a8a43d1782446b5df4ce5d66ac77e238e — the merge of head 830a071a4977276b0bb66b69f3861bb4c09c2ed7 into base dfd8e398aacfa74b8f401a9186814fec093cf010, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dfd8e398aacfa74b8f401a9186814fec093cf010 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI: Test Core (5/6) is red, and the failure is not this PR's. domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN), reviewer of record, 2026-09-27T22:14Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e9f9390243ff398f844f88543017c1f676935b40

① Derived judgments

  • ①1 Save door = query face, both members, every carrier, every position — RIGHT. Executed with tsx on packages/spec/src in two detached worktrees, the head e9f93902 and the merge-base 6ac33a57 (git merge-base origin/main e9f93902): every declared operator of FieldOperatorsSchema.shape (18) × 55 comparand shapes (the parity battery plus a null-prototype object, a Set, NaN, a negative bigint beyond 2^53, 2n ** 53n, {today} / {current_user_id} placeholders, a preset name, [null, ''], ['', null], [null, null], [undefined, 5], a list holding a class instance, a pair of Dates) plus the implicit slot, at 8 positions (top, $and.1, $or.0, $not, $and.0.$or.0.$not, one hop into a relation, two hops under $or, one hop under $not), on 9 carrier parses (FilterConditionSchema, Dataset.filter, Dataset.measures[0].filter, DatasetMeasure.filter, DashboardWidget.filter, Dashboard.widgets[0].filter, Report.runtimeFilter, JoinedReportBlock.runtimeFilter, Report.blocks[0].runtimeFilter): 74,880 cells per worktree, each cell recorded with the shape face's, the type face's and the flag rule's verdict beside the door's. At the head, 0 cells disagree with shapeFace ∨ typeFace ∨ flagRule at the top and combinator positions on every carrier, nor inside a relation on the eight analytics-carrier parses, once the door's two pre-existing arms are modelled ($icontains text arm finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514, bare date-range preset arm Refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time — the ruled C half of #8690, carved out for the spec seat #8793 — the only 291 cells outside the faces' verdict, identical at base). Inside a relation on bare FilterConditionSchema only those two arms fire (the face's reach). Base → head flips: 0 refuse→accept; 16,122 accept→refuse, 0 outside the declared set — 7,560 at the top / combinator positions (840 per carrier parse, all type-face cells: a plain object, a Map, a Set, a class instance, a function, a Symbol, undefined, a bigint beyond ±2^53, a { $field } with a non-string name, as the comparand, an implicit comparand, or an $in / $nin / $between member) and 8,562 inside a relation (504 on each dataset-carrier parse = the type-face cells; 1,410 on each widget / report / joined-block parse = the type-face cells plus every stage-1 and A list inside a nested-relation condition in a dataset or measure filter ({ account: { region: ['a'] } }) passes the save-time schema door and is refused only when the chart runs #20080 shape). M-type table at the head: $eq / $in / $gt / $null / implicit × {plain object, Map, class instance, function, Symbol, undefined, bigint beyond 2^53, and the list-member forms} refused on all 9 parses at the 5 top / combinator positions and on all 8 analytics-carrier parses at the 3 nested positions, accepted on bare FilterConditionSchema nested (as the face, which never descends a relation). M-widget table at the head: $in: ['won', null], $eq: ['won'], implicit ['won'], $gt: null, $ne: ['won','lost'], $null: 'x', $between: [null, 5], $in: 'won' refused inside a relation on all eight analytics-carrier parses (accepted there at base on the widget and both report carriers; refused there at base only on the dataset carriers). Controls, 24 shapes × 8 positions × 9 parses, refused 0 times: { $field } under $gt, a Date, '{today}' under $gte, an implicit '{current_user_id}', $in of placeholders, bigints within 2^53 (5n, 2n ** 53n, a list [1n, 2], a pair [1n, 9n]), plain scalars, null under $eq / $ne, $in: [], a $between pair, $null: true, $exists: false. The words: every type-face cell prints the face's own sentence (Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. …) byte-equal at the top and inside a relation on every carrier, at the slot (runtimeFilter.acct.f.$eq) or the member (runtimeFilter.acct.f.$in.1); 0 of the head's issue messages carry at where; the type face threw nothing but its INVALID_FILTER envelope on any cell. Parse throws: 36 at the head, 36 at the base, the same cells ({ f: NaN } as a combinator member, zod's Unmergable intersection; not JSON-reachable, pre-existing).
  • ①2 The analytics where door refuses each one too — RIGHT. normalizeWhereComparands from service-analytics src/strategies/filter-normalizer.ts at the head, run over spec src (tsx with the @objectstack/spec/* entries and @objectstack/core's one helper path-mapped to source — the container's verify lock was held 15+ minutes by another seat's suite, so no dist was built), on the same 1,040 entries × 8 positions: every M-type and M-widget shape is refused (INVALID_FILTER) at the top, one hop, two hops and under $not; every control is accepted at every position. Compared cell by cell with the head save doors on the five analytics carriers: 0 cells the analytics door refuses and a save door accepts; 1,640 cells the save door refuses and the analytics door accepts, all the two pre-existing schema-door arms ($gt / $gte / $lt / $lte of a bare preset name; $icontains of a non-string), not this PR's.
  • ①3 Fixture and door controls — RIGHT. Each of the 9 carrier fixtures parses success: true with no filter and with { stage: 'won' } at base and head (a refused fixture would have counted as a refused cell). dashboard.zod.ts still imports FilterConditionSchema for GlobalFilterOptionsFromSchema.filter (:8, :1285), which keeps the shared reach; report.zod.ts at the head has no remaining use of FilterConditionSchema (grep: 0), so the import swap is complete.
  • ①4 No over-refusal, three corpora, static census — RIGHT. A TypeScript-AST census (every filter / runtimeFilter / where / having / relatedListFilter property assignment whose initializer is literal data, plus JSON / YAML documents) parsed through the bare condition and the five analytics carriers at base and at head: objectstack examples/** + packages/** (non-test, non-dist, 3,209 files; 1,266 sites, 487 static, 779 non-static — identifiers, calls, spreads), objectui at the .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (extracted with git archive, 2,327 files; 145 sites, 64 static), cloud origin/main 96eb092fbfdc856ffc217c290e0cd609d0b2e2cd (git archive, 738 files; 431 sites, 49 static): 0 verdict flips base → head on any carrier for any site. The sites refused at both (148 / 59 / 29) are same-named keys that are not FilterConditions (view filter arrays, page filter: true, script where strings, i18n strings) or conformance-table rows already refused by stage 1 / finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514 (driver-sql/src/cross-field-conformance-cases.ts:492,497,532, spec/src/data/filter-text-conformance.ts:447,455). A literal grep with lit controls over the same three corpora (non-test): 0 authored filters carrying an object where a value belongs, an object list member, undefined or a non-Date new X under a filter operator, a bigint under an operator, or a nested relation holding a list / operator map — every hit is prose, the type face's conformance table (filter-comparand-type-conformance.ts:264), driver-memory's own $regex: new RegExp lowering, or temporal-conformance.ts's {placeholder} $between pairs (accepted); controls: $field in a scalar slot 51 / 1 / 1, an operator-map first entry 174 / 10 / 14. The objectui producer FilterConditionField.tsx:240 ($in: [null, '']) is stage 1's, objectui#10790. The example stacks were not booted (no dist); their metadata modules are in the static census (app-crm, app-todo, app-multi-package, app-showcase src/**), with 0 flips.
  • ①5 The dedupe moves no verdict — RIGHT; the "one slot, one issue" claim is not true inside a relation on the analytics carriers — flagged in ③. Over the 74,880 cells, 45 cells went from 2 issues to 1 with the verdict unchanged ($between: ['last_7_days'] at the 5 top / combinator positions × 9 parses: the malformed range only, the preset endpoint no longer). No cell went refused → accepted (①1). Measured beside it: 312 head cells carry TWO issues under one slot, all inside a relation on the eight analytics-carrier parses — $icontains with a comparand the type face refuses (12 battery shapes: undefined, a plain / empty / null-prototype object, { $field: 5 }, a Map, a Set, a class instance, a function, a Symbol, a bigint beyond ±2^53) and $between: ['last_7_days']; 303 of them are new at the head (the 9 $between cells on the dataset carriers were already 2 at base). Mechanism, read in the diff: the shared walk (filter.zod.ts:1877) asks the faces only at depth === 0 and otherwise still runs its own $icontains and preset arms at any depth, while the carrier walk (analytics-carrier-filter.ts:177-184) asks the faces inside the relation, so one nested slot is reported by both. Example at the head: Dataset.filter with { acct: { f: { $icontains: new Map() } } } → two issues at filter.acct.f.$icontains (the text arm's …is object ({}), not a string and the type face's Filter comparand is a Map instance…), one at base.
  • ①6 Byte-neutral extraction and fold — RIGHT. JSON.stringify(z.toJSONSchema(S)) for DatasetSchema, DatasetMeasureSchema, DashboardSchema, DashboardWidgetSchema, ReportSchema, JoinedReportBlockSchema, sha256 per schema at the merge-base and at the head: identical for all six (Dataset 288a4e40…, DatasetMeasure 046cfe14…, Dashboard 88394ed0…, DashboardWidget 62ecedaa…, Report 74e30872…, JoinedReportBlock 0e178cf8…). analyticsCarrierFilter is absent from the ui barrel and the root barrel at runtime ('analyticsCarrierFilter' in import('src/ui/index.ts') → false; src/ui/index.ts and package.json name no analytics-carrier-filter), its only importers are dataset.zod.ts, dashboard.zod.ts, report.zod.ts and the parity test, and the diff touches no api-surface/, export-origins, json-schema.manifest/ or authorable-surface/ file. report.zod.ts diff vs the merge-base: the import line (FilterConditionSchema → analyticsCarrierFilter), and the two runtimeFilter declarations at :280-290 (JoinedReportBlockSchema) and :409-423 (ReportSchema) with their docblocks; nothing else. dataset.zod.ts loses the 162-line inline block and calls analyticsCarrierFilter() at the same two slots; the moved body in analytics-carrier-filter.ts differs from the removed one only in the stage-2 lines (isAnalyticsDataObject, the spec argument, the widened docblock).
  • ①7 dropped-refinements.baseline.json — RIGHT. git diff 6ac33a57 e9f93902 on the ledger: 18 added lines, 1 removed — the droppedRefinementSites header 605 → 622 and exactly 17 added sites, 0 removed: ui/DashboardWidget filter, ui/Dashboard widgets.element.filter, ui/JoinedReportBlock runtimeFilter, ui/Report runtimeFilter + blocks.element.runtimeFilter, and the same three positions (…dashboards.element.widgets.element.filter, …reports.element.runtimeFilter, …reports.element.blocks.element.runtimeFilter) in the four installed-package envelopes (manifest.…, data.options[1].manifest.…, options[1].manifest.…, data.packages.element.options[1].manifest.…). Recounted from the file: 212 schemas, 622 sites, equal to the measured header. The committed totals test packages/spec/scripts/dropped-refinements.test.ts at the head: 1 file, 27 passed. What the build names: build-schemas.ts exits 1 on any ledger drift (an undeclared, miscounted, repaired or vanished entry) and CI Build Core (job 108717176346) ran pnpm build at this head with 72 successful, 72 total, 0 cached (the spec build executed, no turbo cache hit) and passed, so the head ledger names exactly the observed population. Reproduced locally under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 18s): OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts at the head with the MERGE-BASE ledger swapped in (blob 849265b1…) exits 1 naming 8 ledger entry(ies) … name a different set of sites — api/AssembledInstalledPackage, api/GetInstalledPackageResponse, api/InstalledPackageAtEitherStage, api/ListInstalledPackagesResponse, ui/Dashboard, ui/DashboardWidget, ui/JoinedReportBlock, ui/Report — with exactly 17 + sites and 0 - sites, and sort-diff of those 17 against the 17 site lines the PR adds is empty (the diff's one other added line is the 622 header); the same build with the HEAD ledger restored (blob 0638d081… == HEAD) exits 0, and git status --short is empty afterwards. No hand-picked site.
  • ①8 Pins — RIGHT. The parity pin at the head: 1 file, 153 passed (vitest run --project local src/data/filter-save-door-face-parity.test.ts). Its CARRIERS (:564-575) holds exactly dataset filter, measure filter, dashboard widget filter, report runtimeFilter, joined report block runtimeFilter, pinned as that list at :636-644; §5 runs the derived operator × battery table one and two hops down on all five and asserts verdict equality with queryFacesRefuse (shape face, type face, flag rule); §6 walks FILTER_COMPARAND_TYPE_CASES (door-refusal rows refused, matches / compiles rows accepted and kept toEqual the input); §7 pins z.toJSONSchema(analyticsCarrierFilter()) byte-equal to FilterConditionSchema.optional()'s and the widget / report descriptions. The rest pin's two AT_THE_DOOR rows assert 400, VALIDATION_FAILED, exactly one details.fields[] entry at selection.runtimeFilter.stage.$eq / …$in.1, the face's sentence and no at where. — the substance; not executed here (the rest closure needs a built tree); read off CI job 108717176443 (Test Core (4/6), the shard that ran @objectstack/rest at this head): src/analytics-filter-refusal-envelope.test.ts 33 tests passed (31 before the PR plus the two new rows), @objectstack/rest 202 files / 3664 passed / 1 skipped — the dev's numbers.
  • ①9 Ablation — RIGHT, reproduced. Through scripts/ablation-replace.mjs (WRAP) in the review worktree: packages/spec/src/ui/report.zod.ts, anchor runtimeFilter: analyticsCarrierFilter().describe('Render-time scope filter') x1 → x0, replacement runtimeFilter: analyticsCarrierFilter().unwrap().optional().describe('Render-time scope filter') x0 → x1, blob 007fe3e5… → cb0bef28…; the parity pin under the mutation: 18 failed / 153 (§5 every operator × comparand cell both hops, the implicit slot both hops, every NESTED_MEMBERS row, the nested CONTROL — the report runtimeFilter carrier column). Restore proven by the script (blob after restore 007fe3e5… == HEAD blob, git diff HEAD empty) and again by hand (git status --short 0 paths, git diff HEAD --stat empty, unwrap() markers on disk 0).

② Semver level

  • minor + BREAKING, Clause-②: no (narrowing) — RIGHT, as the stage-1 precedent. The changeset carries "@objectstack/spec": minor, **BREAKING**, a fix(spec)!: summary, Clause-②: no (narrowing) and, in an HTML comment, the disposition marker adr-0087: registered filter-comparand-types-and-widget-nested-slots-refused-at-save; the PR body carries the same Clause-②: no (narrowing) line (the round-0 stage-1 note about a bare no in the body does not recur). node scripts/check-changeset-no-major.mjs --base 6ac33a57 → no major bump, exit 0 (the level axis is not applicable offline, as it prints). node scripts/check-adr-0087-registration.mjs --base 6ac33a57 → 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] registered filter-comparand-types-and-widget-nested-slots-refused-at-save (new here), exit 0. The FROM → TO table covers each refused shape (plain object, list member, { $field: 5 }, undefined, Map / class instance, bigint beyond 2^53, the nested widget / report shapes) and the HTTP-door code move; the census statement (examples, non-test packages, objectui f8a9d0fb05, cloud 96eb092fbf, 0 producers) matches ①4.
  • The semantic entry filter-comparand-types-and-widget-nested-slots-refused-at-save — accurate for five carriers. surface names the shared reach on every FilterCondition carrier and the nested reach on DatasetSchema.filter, DatasetMeasureSchema.filter, ui.DashboardWidget.filter, ui.Report.runtimeFilter, ui.JoinedReportBlock.runtimeFilter; replacement names the six accepted types, { $field }, the null predicate, the request-time placeholders and the kept bigint; reason restates the 17bd3187 measurement, the read-only face call, the shape → type → flag order, no D2 conversion — each matches ①1–①2. One sentence of reason (and of the changeset) overstates the dedupe inside a relation — ③ below; the accept / refuse set it describes is right.
  • Registry — regenerated, no drift. pnpm --filter @objectstack/spec check:migration-registry at the head: registry.ts is current (295 semantic, 215 retired-key, 199 retired-def); every entries/**/*.ts id (295) resolves in registry.ts, the new id exactly once. Merge-tree probe against CURRENT origin/main 10ea9eb2 (7 commits past the merge-base) from a driverless bare shared clone (git clone --bare --shared, no merge.os-regen config): git merge-tree --write-tree --name-only 10ea9eb2 e9f93902 exit 0, no conflict, tree 1f9293b4…; in that merged tree, every entry file's id (297 = 295 + the two main gained) is present in the merged registry.ts, 0 missing.

③ Boundary flags

  • The head is red on one unrelated flake, so it does not meet the enqueue precondition yet. CI run 36353694714 at e9f93902: 31 success / 6 skipped / 2 failure — Test Core (5/6) and the Test Core aggregate. The shard's only failing test is packages/client/src/auth-get-session-envelope.test.ts:262 (① me() delivers the envelope it declares › parses as the declared envelope, Test timed out in 5000ms inside signedIn() / client.auth.me(), job 108717176436); @objectstack/objectql's ELIFECYCLE in the same shard is turbo cancelling its still-running vitest 150 ms after the client failure (its log carries no failing test, 0 non-✓ file lines). The PR touches no file in packages/client (diff vs the merge-base: empty), and the test sends no filter. Not this PR's; the shard must be re-run and green before enqueue, which is the seat's precondition, not a contract defect.
  • "One slot, one issue" holds at the shared reach but not inside a relation on the analytics carriers (①5). The changeset (Where a face refuses a slot, the schema door's own $icontains and date-preset arms stay silent on it) and the entry's reason (a second issue on a slot a face already refused … is no longer raised) are true at the top and combinator positions and false one hop into a relation on the five analytics carriers, where a nested $icontains with a type-refused comparand or a nested $between: ['last_7_days'] raises two issues at one path (303 cells new at the head). No verdict moves and no pin covers the nested count (§5 asserts only that at least one issue sits under the slot; the NESTED_MEMBERS issueAt rows carry no such shape). A sweep to make the words true (silence the shared walk's arms on a slot the carrier walk judged, or narrow the sentence to the shared reach) is owed; not blocking, since the contract judged here is the accept set.
  • Pre-existing, not this PR's. The two schema-door arms wider than every query face ($gt / $gte / $lt / $lte of a bare preset name, $icontains of a non-string) refuse 1,640 cells the analytics door accepts; the zod Unmergable intersection throw on { f: NaN } as a combinator member (36 cells, identical at base); the #20207 carrier walk's unbounded recursion on a cyclic object.
  • File surface = the claim as amended by the fold ruling. The 12 files of the diff are the claim's (filter.zod.ts, dashboard.zod.ts, the parity test, the semantic entry, the regenerated registry.ts, the ledger, the changeset), the fold ruling's addition (report.zod.ts, the two carriers only) and its accepted mechanisms (filter-save-door-refusals.ts, analytics-carrier-filter.ts, dataset.zod.ts, the rest envelope test); packages/formula/** is untouched. Fixes #20116 closes the collector, whose two open members (release 5857575995: M-widget, M-type) are both measured closed above; the objectui producer stays on objectui#10790.
  • GlobalFilterOptionsFromSchema.filter keeps the shared reach (declared in the Acceptance notes; dashboard.zod.ts:1285 unchanged).
  • Not measured locally: the spec, service-analytics, rest and lint suites beyond the parity pin and the ledger test (CI's Test Core shards 1–4 and 6 are green at the head; shard 5 is the flake above); dispatch-gates (the dev's 89 / 87 run, 2 whole-repo-build gates NOT MEASURED, is not re-derived here). ESLint, --no-inline-config --format json over the 10 changed .ts files at the head: 0 errors, 0 warnings.

Implemented-by: claude/issue-20116-stage-2-type-and-widget
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS


Generated by Claude Code

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 830a071a4977276b0bb66b69f3861bb4c09c2ed7

① Derived judgments

  • ①0 Scope — a DELTA over the e9f93902 record. The full at-tier review passed at e9f9390243ff398f844f88543017c1f676935b40 (scratchpad/pr-20325/review/record.md); only the range e9f93902..830a071a is judged here. git log --parents: two commits — 64dd083b (parents e9f93902 and dfd8e398, "Merge remote-tracking branch 'origin/main'") and 830a071a (parent 64dd083b, "docs(spec): state the one-issue-per-slot dedupe at the reach it holds", 3 files, 17 insertions, 7 deletions). git merge-base origin/main 830a071a = dfd8e398; main gained 10 commits between 6ac33a57 (the prior merge-base) and dfd8e398, and 3 more since (origin/main = d3958bac at review time). Every code file of the PR is byte-identical between e9f93902 and 830a071a (①3), so the prior record's ①1–①9 measurements stand unchanged and are not re-run.
  • ①1 The dedupe sentence is now true at every reach it names — RIGHT. Read against the head code: the shared walk checkFilterConditionComparands (filter.zod.ts:1847-1910) asks the judge only at depth === 0 (:1866 for the implicit comparand, :1886 for an operator slot) and continues past its own $icontains (:1889) and date-preset arms (:1897, :1904, the $between endpoint loop after them) when the judge refused; $and / $or / $not members are re-parsed by the recursive schema, so each starts at depth 0; at any depth above 0 the judge is never asked and the three arms run as before. The judge reportQueryFaceRefusals (filter-save-door-refusals.ts:377-396) raises at most one issue, shape face (:387-388), then type face (:389), then the flag rule (:390-392). The carrier walk refuseNestedRelationComparands (analytics-carrier-filter.ts) asks the judge only insideRelation and never silences the shared walk's arms, so one nested slot is reported by both. The $icontains arm's predicate isRefusedTextComparand is typeof target !== 'string' || target === '', so every comparand the type face refuses (none is a string) also trips the arm — the PR body's "carries two issues" is exact, and the changeset's / entry's "can carry" is a safe subset. Measured with a tsx probe over packages/spec/src at 830a071a and at the merge-base dfd8e398 (six parses: bare FilterConditionSchema, Dataset.filter, DatasetMeasure.filter, Dashboard.widgets.0.filter, Report.runtimeFilter, Report.blocks.0.runtimeFilter; 18 documents; controls { stage: 'won' } and { acct: { region: 'NA' } } parse success: true on all six at both): at the head, { acct: { name: { $icontains: new Map() } } } yields exactly TWO issues at one path (filter.acct.name.$icontains, widgets.0.filter.acct.name.$icontains, runtimeFilter.acct.name.$icontains, blocks.0.runtimeFilter.acct.name.$icontains) on each of the five analytics carriers — the text arm's The filter comparand for field 'name' on operator '$icontains'… and the type face's Filter comparand is a Map instance ({}), which no driver can… — and ONE on bare FilterConditionSchema; the same slot at the top level yields exactly ONE issue on all six, the type face's sentence, and so does each $and.0 / $or.1 / $not member position; the same two-issue count holds nested for undefined, a bigint 2 ** 60, { $field: 5 } and a class instance, and a nested $icontains: 5 (a number the type face accepts) yields one (the arm only). { created_at: { $between: ['last_7_days'] } } at the top level yields ONE issue at …$between (the shape face's requires a [min, max]), none at .0, on all six; nested inside acct it yields TWO on each of the five (…$between from the face and …$between.0 from the preset arm) and one (.0 only) on bare; with the range fixed (['last_7_days', '2026-01-01']) the preset endpoint is reported once at …$between.0 on all six; { stage: { $null: { a: 1 } } } yields one issue, the type face's plain-object sentence, on all six. At the merge-base the same documents show the "before": top-level $between: ['last_7_days'] carried two issues on all six (2 → 1 at the head), nested $icontains: new Map() carried one (the arm) on all six (1 → 2 on the five at the head), nested $between: ['last_7_days'] carried two on the dataset carriers and one on the widget and report carriers (2 on all five at the head), and $null: { a: 1 } read as the flag rule's sentence (the type face's at the head). Head-vs-base verdict table over the 18 × 6 cells: 0 verdict moves on any dedupe-named document (34 cells change issue count with the verdict unchanged, refused at both); the only 3 verdict moves are a control I added — a nested one-bound $between of a NON-preset string on the widget and the two report carriers, accepted at base and refused at head — which is the PR's declared M-widget narrowing, not the dedupe. Each sentence of the new changeset paragraph, the entry's reason and the PR body's §2 (and its behaviour-table row) was checked against these cells; each is true, and each says no verdict moves.
  • ①2 830a071a is text only — RIGHT. Entry: one hunk @@ -54,9 +54,12 @@, wholly inside the reason string concatenation; both versions imported with tsx and compared field by field — the key list id,surface,replacement,reason,acceptanceCriteria is identical, id (62 chars), surface (1044), replacement (707) and acceptanceCriteria (726) are byte-equal, reason grows 2429 → 2720 chars; the file keeps its 18. major prefix and its id-derived name. Registry: one hunk @@ -9734,3 +9734,6 @@ const step18 inside the os-generated semantic:18 region; its 9 changed lines (3 removed, 6 added) equal the entry's 9 changed lines byte for byte once the generator's 4 extra spaces of indent are stripped (registry lines all at 8 spaces, entry lines all at 4); the generator's own read-only check in a scratch worktree at the head, tsx scripts/build-migration-registry.ts --self-test --check, prints self-test: ok and registry.ts is current (298 semantic, 217 retired-key, 199 retired-def), exit 0, with git status --short empty afterwards. Changeset: one hunk @@ -24 +24,5 @@; at both 64dd083b and 830a071a the frontmatter is lines 1–3 with "@objectstack/spec": minor, line 5 is the fix(spec)!: summary, line 7 opens with **BREAKING**, the Clause-②: no (narrowing) paragraph is present (line 68 → 72) and the HTML-comment marker adr-0087: registered filter-comparand-types-and-widget-nested-slots-refused-at-save is the last line (70 → 74), all unchanged.
  • ①3 The main merge 64dd083b is clean — RIGHT. git diff origin/main...830a071a --name-only sorted equals git diff 6ac33a57...e9f93902 --name-only sorted: the same 12 files, none entered, none left. The --stat lines differ only for the three files of 830a071a (changeset 70 → 74, entry 80 → 83, registry 76 → 79; 1024 → 1034 insertions, 252 deletions both times). Per-file blobs: the other 9 files are identical at e9f93902 and 830a071a, and identical at 6ac33a57, dfd8e398 and origin/main (base stable), so their diffs are the prior record's byte for byte; registry.ts's base moved (main gained entries), and the PR's diff on it versus origin/main is 79 insertions and 0 deletions in one hunk at step18, the new entry alone. Registry at 830a071a, my own node check mirroring build-migration-registry.ts over git objects: 714 entry files (semantic 77 + 221, retired-key 29 + 188, retired-def 53 + 146) each resolve in registry.ts, each of the six generated regions holds exactly its directory's id set in ascending id order, the new id appears once. git merge-tree --write-tree origin/main 830a071a against CURRENT origin/main d3958bac (3 commits past dfd8e398): exit 0, no conflict, tree 4eee6ed3d6fb87d5efd121a3e972ed61943affe2 — the same tree from the seat checkout (which carries the merge.os-regen driver config) and from a driverless bare shared clone (no merge.* config), so the queue's plain text merge lands identically. In that merged tree the same check passes: 714 ids resolve, all regions sorted, the new id present once; control at origin/main itself: 713 entries, 0 problems.
  • ①4 The four docs pages are unchanged in the range — RIGHT. git diff e9f93902 830a071a --name-only -- content/docs/ is empty (0 files); git diff 6ac33a57 dfd8e398 -- content/docs/ (what main brought) is also empty, and the two diffs compare identical; git diff origin/main...830a071a -- content/docs/ is empty, so the PR authors nothing under content/docs/. The four pages' blob ids are the same at 6ac33a57, dfd8e398, e9f93902, 830a071a and origin/main: api/data-api.mdx 73c13525…, api/error-catalog.mdx 35342575…, data-modeling/analytics.mdx f1412148…, protocol/objectql/query-syntax.mdx eef62446….
  • ①5 CI at 830a071a — RIGHT, green. GET /commits/830a071a/check-runs: 42 runs, 37 success, 5 skipped, 0 failure, 0 in progress. Every Test Core shard 1–6 and the aggregate are success (the shard-5 flake of the prior ③, job 108724522674, is green at this head), as are Build Core, TypeScript Type Check and its four sub-gates, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Dogfood Regression Gate 1–3 and aggregate, Dogfood Verify CLI, Temporal Conformance, Check Changeset (twice), Flag docs affected by code changes, Check Documentation Links, and the four claim / closing-keyword guards. The five skips, each one the repo expects: Build Docs and Console Pin Gate (CI run 36356181632) are gated on the filter job's docs / console outputs, whose globs (apps/docs/**, content/**, pnpm-lock.yaml, .github/workflows/ci.yml; .objectui-sha, scripts/build-console.sh, scripts/check-console-sha.mjs, scripts/check-console-injection.mjs, scripts/console-spec-probes.mjs, scripts/assert-console-spec-injection.mjs, .github/workflows/ci.yml) match none of the PR's 12 paths (all under .changeset/ and packages/); Packed-tarball smoke (opt-in) is label-gated on needs:pack-smoke, which the PR does not carry (labels: documentation, size/xl, tests, tooling, needs:contract-review, protocol:ui, protocol:data); Auto Label and Check PR Size in PR Automation run 36361021166 (a second pull_request run created 2026-09-28T00:07:45Z, five seconds after the PR's updated_at 00:07:40Z, i.e. the body edit) carry if: github.event.action != 'edited' (pr-automation.yml:141-144, :211-215), and both ran to success at this same head in the push-triggered run 36356181636 (jobs 108724255875, 108724303805). Combined commit status: success (one context, Vercel). The check-suites listing also shows five app suites queued with 0 check runs (vercel, fly-io, claude, cloudflare-workers-and-pages, objectstack-fleet) — apps that raised no check run, not lanes.
  • ①6 The PR body — RIGHT. Read off GET /pulls/20325 at head 830a071a (branch claude/issue-20116-stage-2-type-and-widget, base main): line 1 is Fixes #20116 (once in the body), line 2 is Clause-②: no (narrowing). §2 "One judge" now reads: at the top level and in the combinators the shared walk's $icontains and preset arms stay silent on a face-refused slot; inside a nested relation on an analytics carrier they do not, so a nested $icontains with a type-refused comparand, or a nested one-bound $between of a preset name, carries two issues (citing the prior review's 303 cells); no verdict moves either way — the same statement as the changeset's two new paragraphs and the entry's reason, and the behaviour-changes table row states the dedupe "at the top level and in the combinators … (not inside a relation on an analytics carrier)". Its Tests section names 830a071a as the final head, "text only".

② Semver level

  • minor + BREAKING, Clause-②: no (narrowing) — unchanged, RIGHT. The changeset's bump level, **BREAKING**, fix(spec)!: summary, Clause-②: no (narrowing) and the adr-0087: registered filter-comparand-types-and-widget-nested-slots-refused-at-save marker are byte-unchanged across 64dd083b → 830a071a (①2); the PR body carries the same clause line (①6). The prior record's ② (the check-changeset-no-major and check-adr-0087-registration verdicts, the FROM → TO table, the census statement) is not re-derived: the only changeset lines that moved are the dedupe paragraph's, and they narrow a description, not the accept set.
  • The semantic entry — now accurate at every reach. The prior ②'s one reservation (the reason overstated the dedupe inside a relation) is closed by 830a071a: reason now states the top-level / combinator dedupe and the nested two-issue case separately and says neither moves a verdict, which ①1 measures true; surface, replacement and acceptanceCriteria are untouched (①2).
  • Registry — regenerated, no drift, at the head and in the merged tree against current origin/main (①2, ①3).

③ Boundary flags

  • The prior ③ dedupe flag is resolved by words, not by code — and that is what was owed. The prior record named two remedies (silence the shared walk's arms on a slot the carrier walk judged, or narrow the sentence); the dev took the second. The nested two-issue behaviour is now stated, but still not pinned: no test asserts the count of issues under a nested slot (parity §5 asserts at least one, as before). Not blocking — the contract judged is the accept set, and every sentence now matches the code — but a pin would keep the words from drifting if either walk changes.
  • One sentence still reads wider than its paragraph. "Every document refused before is still refused, and every document accepted before is still accepted" is true of the dedupe (the paragraph's subject, and the sentence now ends "— the dedupe removes only a second issue …"), and false of the PR as a whole, which narrows the accept set by design (①1's control, the changeset's own "What changes"). The same sentence stood in the reviewed e9f93902 text; a reading note, not a defect.
  • Draft state. The PR is draft: true with mergeable_state: clean; undrafting is the seat's step before enqueue, not a contract matter.
  • Not measured here, and why. The prior record's 74,880-cell battery, JSON-Schema hashes, ledger reproduction and ablation are not re-run: every code file of the PR is blob-identical between e9f93902 and 830a071a (①3). Suites are CI's (①5). The CI filter job's log could not be downloaded (the log endpoint's redirect is refused by the outbound proxy, HTTP 403), so the two path-filtered skips are derived from the workflow's own glob lists against the PR's 12 paths rather than read off the job's printed outputs.
  • How the probe ran, disclosed. Build-free: two detached worktrees under the scratchpad (830a071a, dfd8e398) with node_modules and packages/spec/node_modules symlinked from an installed sibling worktree (zod 4.6.1, which satisfies the head's ^4.6.1; tsx 4.23.12), no pnpm install, no build, no suite; the one script run outside packages/spec/src is the registry generator's --self-test --check, whose --check path only reads and compares (writeFileSync sits on the non-check branch). Both worktrees, the bare clone and the probe files were removed afterwards; /home/user/objectstack-seat-ro and /home/user/objectstack were not edited.
  • main keeps moving. origin/main is 3 commits past the merged dfd8e398; the merge-tree against it is clean and its registry regenerates consistently (①3). Nothing in the PR needs a further merge before enqueue on that evidence.

Implemented-by: claude/issue-20116-stage-2-type-and-widget
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 00:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants