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
Merged via the queue into main with commit dd1b803 Sep 28, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20116-stage-2-type-and-widget branch September 28, 2026 00:53
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants