fix(spec)!: the dateRange array arm is exactly two string bounds, and each refusal origin gets a true sentence - #18230
Conversation
…each refusal origin gets a true sentence `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with no length constraint, while the refusal sentence in the same file said "the two-element array [start, end]" and #16322's shipped migration table told an author to write a single day as `['2026-01-20', '2026-01-20']`. So `['2026-01-01']`, `[]` and `[a, b, c]` passed the contract door and were refused by every reader behind it (#17593 aligned all four analytics faces on "not exactly two bounds is a refusal"). Only the TYPE was weaker than the prose beside it. The arm is now `z.tuple([z.string(), z.string()])`, so the type states the arity to the author's compiler before any parse runs. `analyticsDateRangeRefusalMessage` takes the refusal ORIGIN as a required parameter. "Refused at the schema" is the one clause no input can supply, and it was asserted unconditionally — false for every refusal raised past the schema door, which is why `service-analytics` had to overwrite the message rather than reuse it. The `received …` clause now names the arity and the bad bound separately instead of calling every refused array "an array with a non-string bound", which is false when every bound is a string. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
… changeset for the dateRange arity narrowing The one-element window converts to the same day written twice, the shape the shipped #16322 migration table already prescribes. The empty array and three or more bounds get no conversion and the structured TODO: an empty array names no window at all, and a 3+ array names no pair, so deriving either would be the platform inventing the window the author meant. Graded `minor` under the launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`), with the BREAKING banner and the ADR-0087 disposition carrying the breaking-ness, as the sibling accept-set narrowings on this schema did. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…s, never as `any[]` Draft-2020-12 spells `z.tuple([...])` as `prefixItems` and leaves `items` absent, so `format-type.ts`'s array branch rendered the element type of nothing at all. Every tuple in the spec printed `any[]` — a published cell strictly weaker than the schema beside it, which carried both element types all along. Without this, narrowing the dateRange array arm to a two-bound window would have REGRESSED a published line from `string[]` to `any[]`: the card's whole point is a declared surface that says what the contract enforces, so shipping the narrowing with that cell would have been the same defect one layer out. Three published pages gain precision as a side effect and none loses any: `ListView.map.center` becomes `[number, number]`, `$between` becomes `[number | string, number | string]`, and the `FilterArray` variants print their positions. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…er where the arity rule now lives The array arm of `timeDimensions[].dateRange` is a `z.tuple([z.string(), z.string()])`, so two authored shapes in this suite stopped compiling — and both are consequences of that narrowing, not of anything this suite means: - the deliberate one-element window keeps its value and gains a `@ts-expect-error`, because the refusal it pins is the FACE's, raised past a schema door `POST /analytics/dataset/query` never opens; - the cross-object window is annotated `AnalyticsQuery`, because an un-annotated `const` widens `['2026-01-01', '2026-01-31']` to `string[]`. Measured: `pnpm --filter @objectstack/service-analytics exec tsc --noEmit` reports 3 errors in this file with the narrowed arm and 0 with the arm reverted; after this commit it reports 0, with the package's other error count unchanged. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin eaf96f0b1b9177769043e8f5148cadec6e643ae8 && git checkout eaf96f0b1b9177769043e8f5148cadec6e643ae8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b 00ae6c44c75f573ff5f05884873c63c456295f61 && git checkout -B drift-repro b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b && git merge --no-ff 00ae6c44c75f573ff5f05884873c63c456295f61
node scripts/docs-audit/affected-docs.mjs --json b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b
|
Seat note —
|
Seat note — the drift check's ⛔ row is clear, measured. ⛔ No edit, ⛔ no card, ⛔ no docs-only PR owed.
① Does this PR edit a release-owned page? NO.
② Is the flagged page nevertheless now FALSE? That is the part worth checking, because the check lists a page when a code change touches something it names — and a page that this diff falsifies would owe a card even though the PR must not edit it. The anchor is
That is a claim about the predicate being applied, ⛔ not about which shapes the arm accepts. This PR narrows the array arm's arity (exactly two string bounds) and re-words the refusal per origin; it does not stop Instrument check: the same grep over the same file for the drift check's own anchor token ⇒ Row closed as advisory, satisfied. ⛔ Nothing here is a verdict on the diff; the at-tier clause-② review has not run. Generated by Claude Code |
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries Head check: the PR head at adoption is Contract reviewServed-tier: Worktree: ① Derived judgmentsAccept set
Public surface
② Semver levelWhat moved: accept-set narrowing on a published schema; authored type narrowing (compile breaks measured in a consumer, item 2); a required parameter added to an exported function ( ③ Boundary flagsRuling A checked item by item (verbatim notes in Dev Out-of-surface edits (each reproduced, not taken from the PR body): Flags I raise:
Checks run in the worktree (exit status): CI from the API ( Implemented-by: VERDICT: FAIL Generated by Claude Code |
…rktree INCOMPLETE AND UNREVIEWED. This edit was uncommitted in the dispatch worktree when the container restarted and killed the fix-up round; the seat committed it so the work is not lost, and has NOT reviewed it. Observed state only: 1 modified path, .changeset/17598-analytics-date- range-two-bound-window.md, on top of 5db9a5d. It addresses one of the three fix-up items (the inaccurate justification sentence). The other two items are NOT in this commit. The continuing round diffs this rather than trusting it. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…ller, and the arity refusal keeps one wording on the wire F1 — `packages/spec/package.json` ships `src/**/*.zod.ts` literally, so the TSDoc at `analytics.zod.ts` reaches npm verbatim. It listed `POST /analytics/dataset/query` as a runtime-origin caller "which types its selection from `AnalyticsQuery` but never Zod-parses it". Measured on this tree, that route answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with a message ending `Refused at the schema (…)`: since #17058 its door parses the selection's shared members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)` ahead of the executor. The sentence now names `queryDataset` / the dataset executor reached IN PROCESS as the runtime origin and states that every REST analytics route is a schema-origin door. Same correction at the core echo (which pre-exists on the base) and at the `service-analytics` test comment. F2 — on both REST doors the tuple arm's own `Too small: expected array to have >=2 items` rode along as a second `fields[]` entry beside the prescription: one condition, two wordings (#5240), introduced by this card's narrowing (on the base a 1-element array was not refused by the schema at all). The branch issues that land at the union's OWN path are now dropped in `fieldsFromZodIssues`, the one mapper both doors share, keyed on `isAnalyticsDateRangeRefusalIssue`. Branch issues naming a DEEPER position (`dateRange.1`, the non-string bound) are kept — this narrows a restatement, never a diagnosis — and no other union is touched. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…ne, and the wire half is declared F4 — the justification claimed `minor` was "the same grade every other accept-set narrowing on this schema has taken", which sourced nothing. The rescue commit replaced it with an attribution to #16322; measured, that is not the string-arm closing either. #16322 is the DRIVER half of #16041, and its own changeset describes its `"@objectstack/spec": patch` entry as "a `PROVENANCE_WAIVERS` row only" — not an accept-set narrowing. The accept-set narrowing on this schema is #16041, released as `@objectstack/spec` minor (`packages/spec/CHANGELOG.md` 17.4.0, under Minor Changes). The sentence now names that precedent, its grade and where to read it, and disposes of the neighbouring entry so the next reader does not re-litigate it from the same mis-reading. ⛔ The LEVEL is untouched — `minor` is ruled. `@objectstack/types` and `@objectstack/rest` join the changeset: both publish a changed wire (one `fields[]` entry for an arity refusal instead of two), and the body now states that client-visible effect and its two edges. `analytics.zod.ts`'s "a single prescriptive issue rather than the tuple arm's own too_big/too_small text" is true at `error.issues` and was being read as a claim about the wire, where the ADR-0114 union expansion had made it false. The comment now says which half it covers and where the other half is enforced. Also hardens the new filter: an issue carrying an `errors` array but no `path` now returns untouched instead of reaching a recogniser that reads `path.length`. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Seat disposal — the ⛔ release-owned row from Docs Drift Check is a false positive, measured. ⛔ Nothing to edit, nothing to file.
The check flagged one read-only page on head
The check invites exactly this reply — 「a wrong row is reportable rather than merely annoying」 — so here is the reading. Both candidate carriers on that page, read on
Counted, with a control so the single hits are not read as a missing corpus: ⇒ The page names neither the schema nor the error code this PR changes. The row is an anchor collision on a generic zod issue-code token. Disposal, per the guardrail's own two branches ( Generated by Claude Code |
Contract reviewServed-tier: Re-review after the rework that claims F1 + F2 + F4. Every reading below is my own, taken 2026-09-15T01:31Z–01:5xZ against ① Derived judgmentsAccept set — narrowed, on exactly one schema, in exactly the ruled direction.
Error / refusal wire shape — one restatement dropped, every diagnosis kept. Exported symbols — none new on a published entry. The shipped-prose check, site by site.
② Semver levelWhat moves: an accept-set narrowing plus an authored-type narrowing on a published Convention, read on The rewritten justification, checked clause by clause against the tree — every clause holds, and it corrects the previous record's F4. ③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…real residue The rework's own docblock in `core/src/utils/analytics-date-range.ts` carried two falsifiable clauses, and both citations it added pointed at a card number a reader cannot open. Prose only — no behaviour, no assertion, no bump change. - `:216-218` said "the one caller that ... DISCARDS the message is the REST dataset door". Measured on this head: four non-test callers of `analyticsDateRangeUnrecognizedError(`, of which THREE replace the message — the dataset door (`rest/src/analytics-selection-door.ts:197`) and the two face-side array arms (`service-analytics/src/date-range-array-arm.ts:71`, `driver-memory/src/memory-analytics.ts:777`). Only core's own string resolver (`:260`) lets the sentence leave. The paragraph now names the set. - `:225-226` said "on EVERY REST analytics route the schema door refuses first and this never fires". The array arm is `z.tuple([z.string(), z.string()])` with bare bounds — no `.min(1)`, no format refinement (spec `data/analytics.zod.ts:501`) — so `['','']` passes the union and reaches this constructor at each face past every door; only its message is replaced. The paragraph now states what the door does refuse and names the residue it cannot. - Both `#17058` citations this diff added are now `PR #17548, the PR that landed that door for card #17058`. `GET /issues/17058` answers 404 on the credential that answers 200 for `17548`; the `analytics.zod.ts` site ships to npm (`packages/spec` `files[]` carries `src/**/*.zod.ts`), so it was published prose pointing at a dead link. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Third at-tier round; scope is the prose-only fix-up the seat ordered off record ① Derived judgmentsSize and base, measured. G1(a) — the caller count, my own (02:55:32Z).
⇒ three of four discard the sentence; the shipped paragraph ( G1(b) — the residue predicate, verified independently, and the seat's parenthetical would have been false.
G2 — both status codes, my own credential, back to back (02:56Z, the GitHub MCP credential, The shipped Two findings outside the ordered edits, recorded, no verdict effect. (a) The hand-over read core's docblock as unpublished because ② Semver levelNot re-litigated; confirmed unmoved. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位采纳达档记录
|
Provenance — carriers cleared against the at-tier PASS, and the three landing pre-checks with the time each was taken
Record of the clearing: comment
Carriers cleared in one stroke and read back: PR
On the changeset level, so the record is complete
Review 2 verified the justification's precedent clause by clause against ⏳ Flipping ready and enqueuing. Landing is confirmed git-side on Generated by Claude Code |
…tack-ai#18243) Fixes objectstack-ai#18229 ## What changed `scripts/pm/check-half-states.mjs` only. H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` — the wide body-first/branch-fallback delivery relation — as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped, so acting on it hangs the gate on a card nothing will ever close — where the stroke that clears gates never arrives. Three edits: 1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read of `deliveryEvidence`'s existing grading, never a second keyword parser. It answers "does this PR CLOSE #n", which is the question the gate's clearing stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer false. 2. **H31's own population filter** — the carrier comparison runs over the PRs `bindingClosesCard` accepts. Those rows are byte-identical to before. 3. **A DECLINED row for every other binding** — a weak-bound delivering PR whose carrier differs from the card's still produces a row, one that names both carriers and the binding it read, states that it declined to judge, and prescribes no write. A weak-bound PR whose carrier agrees produces nothing, exactly as before. `TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one spelling across H13's exemption list and H31's new clause. ## Why this seam, and how the shared invariant survives The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids it (「⛔ Do not narrow it here to serve H8: that would make the live half invisible to the rows that exist to see it」), and H8's open side, H35's sibling resolver, `claimDelivery`, H53 and the `check-clause2-carriers` pairing all still read it wide. The narrowing is H31's own filter over the population that relation hands it, so the rows still agree about which PR delivers which card and differ only about which binding makes a CARRIER PAIR — a question only H31 asks. Both docblocks that state the invariant were updated to say so rather than left to imply the old thing. Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's own uniform: a split that was never judged renders identically to a board whose two carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」. Hence the declined row, and hence its scope: it speaks only where the carriers actually differ, because a standing row per tracker per sweep is the disease, not the cure. The declined row is deliberately NOT marked `UNJUDGED_MARKER` — that marker buys trim priority ahead of judged rows, and a decline must never sort ahead of a real carrier split in the same `gate` band. ## Measured on the live board Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run 34919049964, carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212 (draft, via a `Part of` declaration). Replayed offline against the 9 open PRs and the 9 open cards those PRs name (the exact H31 candidate set — a card no open PR names can never reach the comparison): | card | `tracking` | delivering evidence | row before | row after | |---|---|---|---|---| | objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none | | objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none | | objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none | | objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none | | objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none | | objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding | **DECLINED row** | | objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none | | objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none | | objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none | One row changes, and it is the expected one. Every other delivering binding on the live board is a closing keyword, so nothing else moves. ## Tests `pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs --self-test`): **4042 cases pass** before, **4075 cases pass** after, exit 0 — 33 new cases. The pin and the control both land, per the card's own remedies: - pin: the `Part of`-only tracker still produces a row (⛔ not a silent skip), the row says `DECLINES to judge`, names the binding it read, explains why a ruling anchor can never clear a gate, and prescribes NOTHING; it is asserted to be neither the old action-shaped sentence nor a LOUD row nor an UNJUDGED-ranked one. - control: a closing-keyword binding whose card lacks the gate — the row STANDS, with the unchanged action-shaped sentence. - plus: agreement on a weak binding stays clean both ways; a weak-bound card without `tracking` still reports the split but without the ruling-anchor clause; an adjudicable split outranks a decline and names only the closing-bound PR. **Ablation** (one-off, restored; run from the committed fix). Mutating `bindingClosesCard` back to the pre-fix wide population (`deliveryEvidence(pr, n) !== null`) turns the self-test RED: ``` == anchor counts BEFORE == old-text occurrences: 1 / new-text occurrences: 0 mutation written == anchor counts AFTER == old-text occurrences: 0 / new-text occurrences: 1 HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 MUT blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75 VERDICT ablation-exit=1 ✗ check-half-states self-test: 11 of 4075 case(s) failed. == restore leg == REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 old-text occurrences after restore: 1 / new-text occurrences after restore: 0 git diff HEAD bytes: 0 ``` The mutation is proved on disk by the blob hash moving off the HEAD blob and by the two anchor occurrence counts flipping; the restore leg is proved by the blob hash returning to the HEAD blob and by an empty `git diff HEAD`, not by an exit code. There is no build step and no `dist/` for a repo-root `.mjs`, so the rebuild half of the ablation preflight does not apply here. **Gates** — derived with no paths, at head `e0a4efa`: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 41 command(s) node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 41 exited 0, each captured by redirect before any pipe. The gate script's own wiring is inside that set: `pnpm check:pm-half-states` is the package.json step lint.yml runs, and it is the script's `--self-test`. **Lint** — a declared narrowing, not the farm. `eslint scripts/pm/check-half-states.mjs --no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0 errors, 0 warnings**, exit 0. The population is read from eslint's own config, not guessed — an `ESLint` instance over `git ls-files` reports `tracked=8674 in-scope=6755 ignored=1919 no-rules=0`. Invariance: this repo runs one `eslint.config.mjs` which enables type-aware linting for no file at all (every `parserOptions` in it is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero `project`/`projectService` hits; the config says so itself at its `QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff — so no rule's verdict on a file this PR did not touch can move because of it. The repo-wide `pnpm lint` run is CI's. `skip-changeset`: `scripts/pm/**` is PM tooling and ships in no package's `files[]`, so nothing published moves. ## Acceptance notes - `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this is in-seat review. `.github/workflows/half-state-patrol.yml` and anchor objectstack-ai#9857 are untouched: the workflow runs the script from `main`, so the fix reaches the anchor at the first scheduled sweep after landing. - objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier that `scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic tracker reached through a `Part of` line. It is out of scope here and remains open. `bindingClosesCard` is exported for it: that file already imports `deliveryEvidence` and `prDeliversCard` from this one, so the fix there is an import rather than a second copy of the predicate. - noted, not filed: the sibling install of this script in `objectui` (`scripts/pm/check-half-states.mjs`) has already drifted from this copy (different md5 at `b3b43b6`), while this file's header describes the pair as copied VERBATIM. No gate can hold two repos equal, so the wording is an aspiration rather than a claim — carrier: the next dev dispatched on a cross-repo patrol card. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
objectstack-ai#18275) Fixes objectstack-ai#18232 Clause-②: no `packages/services/service-analytics/src/date-range-array-arm.ts` overwrote `err.message` with a SECOND wording for `ANALYTICS_DATE_RANGE_UNRECOGNIZED`. Its own comment justified that on two specific grounds — the shared sentence judged a bare STRING against the preset vocabulary, and it ended with "Refused at the schema". PR objectstack-ai#18230 removed both: `analyticsDateRangeRefusalMessage` describes a non-string by what is WRONG with it (`describeRefusedDateRange`) and takes `origin` as a required parameter. With the grounds gone the second wording is what the \objectstack-ai#5240 convention exists to prevent, so the arm now raises the shared sentence unchanged. Measured against `origin/main` at the branch point `1a02ef17d`, ⛔ not against the card's text — the card was filed against a tree that did not exist yet. The premise holds: `analyticsDateRangeRefusalMessage(input, origin: 'schema' | 'runtime')` is on `main` (spec `analytics.zod.ts`), the array arm is `z.tuple([z.string(), z.string()])`, and the overwrite was still there. ## Which halves this delivery covers | half of the card | status | |:--|:--| | the second wording for one condition | **DONE** | | header sentence ① — the array arm is "a bare `z.array(z.string())` with no length constraint" | **DONE** (now `z.tuple`, ruling A on \objectstack-ai#17598, landed by \objectstack-ai#18230) | | header sentence ② — "Tightening `AnalyticsDateRangeSchema` … is deliberately NOT done here" | **DONE** (it has been done, upstream) | | header sentence ③,⚠️ **not named by the card** — "`POST /analytics/dataset/query` … never Zod-parses it, so the schema door is BEHIND these faces" | **DONE** — measured false in the same paragraph: since PR \objectstack-ai#17548 that route parses `timeDimensions` through `AnalyticsQuerySchema.pick(…)` in `rest/src/analytics-selection-door.ts`, wired ahead of the executor in `rest-server.ts` | | the SAME defect in `driver-memory` | ⛔ **NOT DONE** — different package, see Acceptance notes | ## Driven, not concluded from shape Both wordings a caller actually receives for the same condition, through the real call path (`explicitDateRangeWindow`, the one reading all four faces in this package call), with `@objectstack/core` and `@objectstack/spec` resolved through their built `dist`: **Before** — `['2026-01-01']`, two different sentences: ``` [A] face: [service-analytics] dateRange ["2026-01-01"] is a 1-element array, not a window. An explicit window is the TWO-element array [start, end] … Refused (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) rather than guessed: this package's four analytics faces read an odd-sized array three … [B] shared: dateRange must be a date-range preset name (today, …) or an explicit window is the two-element array [start, end] … ; received a 1-element array, not the two bounds [start, end]. Refused past the schema door, by the analytics reader that received it (…/ 400). IDENTICAL(A,B)? false (same for [], ['a','b','c'], [null,null], ['',''] — 5 shapes, 5 disagreements) ``` **After** — one wording, byte-for-byte, on all five shapes: ``` IDENTICAL(A,B)? true ×5 [C] core constructor message === [B]? true ×5 ``` The envelope never moved: `code` + `status` have always come from the one shared constructor, and the cross-package conformance kit judges exactly those two (`analytics-date-range-conformance.ts` `judgeRefusal`), so it is unaffected. ## Tests `packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts` pinned the message with four `toContain`s on literals of the wording being removed. That pin is satisfied by ANY private wording that quotes the contract — which is what stood here. Replaced with the stronger pin: - **identity**, across all 4 faces × 4 shapes: the message IS `analyticsDateRangeRefusalMessage(range, 'runtime')`; - the clauses identity alone cannot vouch for (identity tracks the builder wherever it goes): what is wrong with what arrived, the two-bound contract, the single-day spelling, and the RUNTIME origin — with the schema-origin sentence and the `[service-analytics]` prefix as negative controls. ``` pnpm --filter @objectstack/service-analytics test Test Files 111 passed (111) · Tests 2399 passed (2399) pnpm --filter @objectstack/service-analytics typecheck exit 0 pnpm --filter @objectstack/core typecheck exit 0 pnpm lint (repo-wide eslint, no narrowing) exit 0 ``` **Ablation** (fix committed first; mutation proved on disk by marker count and `git hash-object` vs the HEAD blob; restored by `git checkout HEAD -- path`, proved by blob equality and an empty `git diff HEAD`). Predicted direction RED, observed RED: reinstating a second wording on the arity branch alone fails 2 of 23 — ``` AssertionError: ObjectQLStrategy.dateRangeBounds answered one element — the card's shape with a wording of its own: expected '[service-analytics] ABLATION second w…' to be 'dateRange must be a…' Tests 2 failed | 21 passed (23) ``` ⭐ The other 21 stayed green, which is the point: the `code`/`status` pins cannot see a second wording, and did not. ## Gates — denominator Re-run in full on the CURRENT head `cf1d1ae49` after the changeset note below, ⛔ not carried over from the earlier head. `node scripts/pm/dispatch-gates.mjs --commands` (derived in-process from the merge-base change set, ⛔ not from a hand-written diff): **64 derived · 64 run · 0 NOT MEASURED · 0 UNRUN**, reconciled with `--ran` carrying one recorded exit code per family. On the fresh worktree FIVE first exited **3 = PREREQUISITE NOT MET** (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt` — they read built output); the workspace was built (`turbo run build`, 72/72, FULL TURBO off the shared cache) and all five then exited 0. Recorded as the single final code per family, ⛔ never as a pass. `pnpm --filter @objectstack/service-analytics test` re-run on this head: 111 files / 2399 tests passed. `pnpm check:slot-lookup` exit 0. `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over the changed files (no matches). Exit codes captured before any pipe.⚠️ **Declared, ⛔ not closed:** that derivation prints a **STALE TREE** warning — this branch is at least 5 commits behind `origin/main`, and 4 of the files the family list is derived FROM changed across that range (`package.json`, `scripts/check-required-contexts.mjs`, `scripts/check-self-test-wired.mjs`, `scripts/pm/check-dispatch-gates.mjs`). So the 64 is this tree's answer, and a family added on `main` since the branch point would not appear in it. Closing that needs a merge of `origin/main`, which would move the diff under review mid-review — left to the seat deliberately rather than taken unilaterally. ## Clause ② — derived as `no`, with controls ⛔ Not from the word `export` and ⛔ not from `dist/index.js`. The published entry is `exports` → `./dist/index.{js,cjs}` (+ types) with `files: [dist, README.md, CHANGELOG.md]`, i.e. the single barrel `src/index.ts`; the derivation walks that barrel's re-export list transitively and collects every symbol reachable from it, at `origin/main` and at HEAD. - `@objectstack/service-analytics`: **56 → 56**, sets identical. `@objectstack/core`: **287 → 287**, sets identical. - The changed file is **not reachable from the barrel at all** — `explicitDateRangeWindow` is internal, so no published symbol moved. - Positive controls: `AnalyticsService` FOUND; `analyticsDateRangeUnrecognizedError` FOUND (core). - Negative controls: `arrayArmRefusal` (module-private) NOT reachable at either ref; a nonexistent name NOT reachable. - **Sensitivity control** — because "zero added `export` lines" is not an answer: an injected `export function` in the barrel moves the count **56 → 57** and is listed as added, while the same injection in the unreachable file is invisible. The instrument can see a new export; it is not seeing one here. (Injection proved on disk, restored, blob-equal.) - No new member on an already-exported class: the only non-comment change deletes a module-private function and replaces two `throw` expressions. The `@objectstack/core` diff is **comment-only** — every added/removed line begins with ` *`, checked mechanically. - The payload gains no key: `code`/`status`/`message` are unchanged in shape; only the `message` VALUE moves, and it moves ONTO the declared contract, which is the narrowing direction. `scripts/pm/check-widening-tells.mjs --declaration no` reads **NOT MEASURED** on this diff (no declared surface covers these four files) — recorded as such, ⛔ not as a pass. ## Changeset `patch` for both packages, and it now carries the upgrade note the envelope paragraph did not: for an ARRAY `dateRange` the shared sentence DESCRIBES the shape where the removed one echoed the value, so a log line that used to carry the offending array no longer does — for EVERY array shape this face refuses, measured on all five (`[null, null]` now reads `received an array with a non-string bound`; `['', '']`, where the description carries least, reads `received a two-element array`). A bare STRING `dateRange` is still quoted back. `@objectstack/core`'s edit is comment-only, but that JSDoc **is published**: `grep -c "Three of the four callers" packages/core/dist/index.d.ts` = 1 (positive control: the symbol itself, 3), so the shipped `.d.ts` bytes move and `skip-changeset` would be wrong. ## Acceptance notes **Reported to the dispatching seat for filing, ⛔ neither filed nor fixed here.**⚠️ Deliberately not filed: repo-scoped REST is the read channel on this session (`/search/issues` answers `403 — sessions are bound to their configured repositories`), so dedupe fell back to one targeted semantic search, and that search did not return \objectstack-ai#18232 itself — the known-must-hit control for this exact subject. A dedupe whose control misses is not a reading, and filing on it would be filing blind. 1. `packages/drivers/driver-memory/src/memory-analytics.ts` keeps a THIRD wording for this same condition — its own local `explicitDateRangeWindow` overwrites `err.message` with `[driver-memory] dateRange … `. Same contract (\objectstack-ai#5240, quoted from `analytics.zod.ts`), same envelope, different package. Out of this card's file surface; fixing it would add another package's test surface to this PR. 2. `describeRefusedDateRange` (`packages/spec/src/data/analytics.zod.ts`) cannot describe `['', '']`. Its comment states "Two bounds is the arity the contract asks for, so the only way such an array reaches a refusal is a bound that is not a string" — measurably false: every face also refuses an EMPTY string bound, and `@objectstack/core`'s own header names `['', '']` as exactly the residue that reaches these faces. The sentence an author gets is therefore "…must be … the two-element array [start, end] …; **received a two-element array**."⚠️ This PR makes that sentence visible at the `service-analytics` face (before it, the face said "has a bound that is not a date string (string)", which is self-contradictory in its own way). `packages/spec` is out of bounds for this card, so this is reported, ⛔ not fixed. **Noted, not filed:** `@objectstack/core`'s prose was updated in this PR to stay true about which callers still supply their own message — it named `service-analytics` as one of three, which this change falsifies. Carried here rather than left for a later reader. Generated by Claude Code in session `session_01URLHobLUJB9K1ABV6ofdjj`; branch `claude/issue-18232-analytics-second-refusal-wording`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17598
Ruling A (comment 5642584462, decision batch #117 item 3, 2026-09-12; re-affirmed 5651068700 on 2026-09-13). Both items of the card land here — see "Why item 2 lands here" below.
1. The array arm refuses anything that is not exactly two string bounds
AnalyticsDateRangeSchema's array arm wasz.array(z.string())with no length constraint; it is nowz.tuple([z.string(), z.string()]). A tuple rather thanz.array(z.string()).length(2)on purpose: the arity is then stated to the author's compiler before any parse runs, which is the ruling's "the type says what the prose says".The union's
errormapper is unchanged in kind, so an arity refusal is still ONE prescriptiveinvalid_unionissue attimeDimensions.N.dateRangeand still carries the structural handleisAnalyticsDateRangeRefusalIssuelifts intoANALYTICS_DATE_RANGE_UNRECOGNIZED/ 400 — not the tuple arm's owntoo_big/too_smalltext.Pinned in
packages/spec/src/data/analytics-date-range-two-bound-window.test.ts:[],['2026-01-01'],['2026-01-01','2026-01-31','2026-02-28']and['{7_days_ago}']are each refused with one issue at the field's own path, throughAnalyticsQuerySchemaand through thePOST /analytics/querybody schema; the two-bound windows and every preset name still parse; and the authored TYPE narrows too (a@ts-expect-errorcase).2. Each refusal ORIGIN gets a true sentence, with the origin as a PARAMETER
analyticsDateRangeRefusalMessage(input)becomesanalyticsDateRangeRefusalMessage(input, origin),originbeing'schema'or'runtime', required, with deliberately no default — a defaulted origin makes the same false assertion silently for every caller who does not think about it.Two clauses were false for an array refused past the schema door:
'runtime'answers "Refused past the schema door, by the analytics reader that received it", and the two sentences are otherwise byte-identical (pinned by a replace-and-compare assertion, so the{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 one-condition-one-wording convention is mechanical rather than asserted).describeRefusedDateRangenames the ARITY and the bad bound separately and claims neither when it is not true:['2026-01-01']now reads "received a 1-element array, not the two bounds [start, end]", while['2026-01-01', 3]keeps exactly the description it had, because that one was true.The ruling's acceptance criterion — the wording is true BOTH BEFORE AND AFTER the arm narrows — is itself a test:
['2026-01-01']is described correctly at both origins, and answering item 1 did not falsify item 2's wording.3. Changeset and ADR-0087 entry
.changeset/17598-analytics-date-range-two-bound-window.mddeclaresminor, not themajorthe ruling's execution note 1 names, and says so in its own body.scripts/check-changeset-no-major.mjsrefuses amajoroutright: every publishable package is in the Changesetsfixedgroup, so onemajorpromotes the whole stack, and during the launch window this repo ships breaking changes asminor. The information carriers the guard names in its place are both present — the BREAKING banner and the ADR-0087 disposition marker. Measured:check-changeset-no-major.mjs --base origin/mainexit 0,check-adr-0087-registration.mjs --base origin/mainexit 0.The ADR-0087 semantic entry is
packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts. The18.prefix is the TARGET PROTOCOL MAJOR, not a sequence number:packages/specis at17.4.0, the sibling18.analytics-time-dimension-date-range-vocabulary-closed.ts(#16322, the same schema's string arm) is filed the same way, and the generated table lands it inMIGRATIONS_BY_MAJOR[18].semantic—check:migration-registry,check:spec-changesandcheck:upgrade-guideall green, so the projections agree.Conversions, per the ruling: one element becomes the same day twice; the empty array and 3+ bounds get the structured TODO and no conversion, because there is nothing to derive a pair FROM. No D2 conversion and no stored-metadata rewrite — rewriting
['2026-01-01']at load would be the platform deciding silently that the author meant one day rather than a window whose end they forgot.Scope: the two out-of-declaration edits, measured and then decided
The claim declared
packages/spec/src/data/analytics.zod.tsplus its tests, one ADR-0087 entry and a changeset. Two edits reach outside it. Both were MEASURED before being kept, by ablation with on-disk mutation proof and a restore proof.A.
packages/core/src/utils/analytics-date-range.ts— REQUIRED CONSEQUENCE, keptIt is not one of the four analytics faces PR #17593 landed. Measured from that PR's own file list: all 8 files are under
packages/services/service-analytics/plus its changeset;packages/coreis not among them. This file is the shared ADR-0112 envelope constructoranalyticsDateRangeUnrecognizedErrorthat those faces — and driver-memory, and the REST selection door — all call, and it predates #17593.It is the only non-test call site of
analyticsDateRangeRefusalMessagein the monorepo (grep overpackages/**: the other five files name only the constructor, whose signature is unchanged). Makingoriginrequired is item 2 of the ruling, so this call site MUST state the origin it alone knows.Ablation, one file reverted to the merge base, nothing else:
The narrowing cannot land without it:
TypeScript Type Checkis one of the six required contexts.B.
packages/spec/scripts/lib/format-type.ts— REQUIRED CONSEQUENCE, keptThe reference-docs renderer read only
items. Draft-2020-12 spellsz.tuple([...])asprefixItemsand leavesitemsABSENT, so the array branch rendered the element type of nothing and printedany[].Measured directly, both renderer versions run over the REAL generated schema for this field (
packages/spec/json-schema/data/AnalyticsQuery.json,properties.timeDimensions.items.properties.dateRange):So landing item 1 WITHOUT this fix does not fail the docs build — it silently rewrites
content/docs/references/data/analytics.mdxandapi/analytics.mdxfromstring[]toany[], i.e. this card's narrowing would make the published reference page say the two bounds may be anything. A regenerated page that is wrong is the criterion, and this is it. Confirmed the other way too: with the branch reverted,check:docsexits 1 and names all four pages, andscripts/format-type.test.tsgoes 1 failed / 89 passed.Collateral, and unavoidable: the branch is one branch in one renderer and cannot be scoped to
dateRangealone, so two pre-existing tuples stop readingany[]in the same regeneration —FilterCondition.$between([number | string, number | string]) andListView.map.center([number, number]). Both are corrections in the same direction, on generated pages, with no source change behind them. They are called out here rather than filed, because there is no PR in which they could land separately.C.
packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts— a THIRD required consequence, found this roundNot in the inherited diff. Narrowing the authored type breaks three sites in this suite, measured:
(The 36 that remain are 24 TS2307 plus their implicit-any cascade, from
@objectstack/corehaving nodistin this worktree. They are identical in all three runs and CI builds the closure.)Line 220 is a deliberate one-element window: the value is KEPT and gains
@ts-expect-error, because the refusal this test pins is the FACE's, raised past a doorPOST /analytics/dataset/querynever opens — deleting the case would delete #17593's own coverage. Lines 499/503 are an ordinary two-bound window whoseconsthad no annotation, so TypeScript widened the literal tostring[]; it is annotatedAnalyticsQuery. Neither touches any face's behaviour. No open PR touchespackages/services/service-analytics.A repo-wide sweep for the same two shapes found no other site: exactly two
dateRangearray literals in the tree have arity other than 2 (this one, and this card's own@ts-expect-errorcase), and every remaining literal sits in a directly contextually-typed position.Premise checks
Two line numbers in the ruling and the card are stale. The ruling cites the array arm at
analytics.zod.ts:326andanalyticsDateRangeRefusalMessageat:284. Onorigin/main1bdbf82cb5those lines carry unrelated text; the live ones before this diff were:418and:376(they are:496and:425after it). Both symbols are unique by name, so the ruling's subject is unambiguous — only the citations moved.Why item 2 lands here:
#17918does not exist. The 2026-09-13 re-affirmation routes item 2 to a split card,#17918, and permits "one PR or #17918 first".GET /repos/objectstack-ai/objectstack/issues/17918returns HTTP 404 on a credential that answers 200 for #17593, #17596, #17124 and #16322 on the same call shape, so the 404 is that card's own and not a permissions artefact. Item 2 therefore has no other carrier, and the ruling's own permission to land both in one PR is what is being used here. This PR does not name that number in any closing keyword.Clause-②: noholds against the mechanical check.scripts/pm/check-widening-tells.mjs --declaration no --diffover this branch's full diff: exit 0, no widening tell. 3 files judged against a declared surface (the schema source, the migrations registry and the new entry), 11 NOT MEASURED — the ADR-0087 registration did not fire T4. The declaration stands as the ruling wrote it and is not flipped.Verification
Control, at the tip:
Negative control for item 1, the whole point of the new suite: with
analytics.zod.tsreverted to the merge base (on-disk proof: tuple-arm occurrences 1 -> 0; blob 69e3ff58f4 -> 8c2cce906a), the two spec suites go 10 failed / 13 passed — everyrefuses ...case and every origin case. Restored fromHEAD,git diff HEADempty.Honest reading of the sibling suite:
analytics-date-range-closed-vocabulary.test.tsPASSES under that ablation. Its only edits are the now-required'schema'argument (ignored by the one-parameter function) and one renamedittitle — "keeps the array arm exactly as it was" became "still accepts every two-bound array window", because the old title is false after this change. No assertion in it was weakened, deleted, loosened or skipped; its three windows all had two bounds already, and the diff is 3 changed lines.60 gate families run in total, 58 exit 0. Two did not measure anything and are recorded as such, not as passes:
check:type-check-debtexits 3 =PREREQUISITE NOT MET(29 workspace dependencies have no built type entry point in this worktree) andcheck:skill-examplesexits 1 on the same prerequisite (packages/client-react/distholds no declarations). Both build the closure in CI. The remaining derived families are repo-wide scans left to CI, perscripts/pm/dispatch-gates.mjs --ran(115 derived, 30 recorded in the first reconciliation pass, 30 more run after it).Acceptance notes
Noted, not filed — each names who would meet it:
packages/services/service-analytics/src/date-range-array-arm.tsoverwrites the shared refusal sentence and documents why: "the shared wording ... ends with 'Refused at the schema', and neither is true of an array refused past the schema door by a face". After this PR that justification is gone —analyticsDateRangeRefusalMessage(dateRange, 'runtime')now says something true — so the repo keeps one condition with two wordings for no remaining reason. That file is fenced to PR fix(analytics): a dateRange array that is not a two-bound window is refused once, instead of meaning three different things #17593's landing for this round. Successor: whoever next touches that file, or a follow-up that finishes the{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 convergence item 2 opened the door for.AnalyticsDateRangeSchema's array arm is a barez.array(z.string())with no length constraint" and "TighteningAnalyticsDateRangeSchemaitself ispackages/spec's call and is deliberately NOT done here". Both sentences are true of the tree this PR starts from and false of the tree it produces. Same fence, same successor.content/docs/references/data/filter.mdxandui/view.mdxgain corrected tuple cells with no source change behind them, as set out in scope decision B. Successor: none needed — they are generated, and the regeneration is in this PR.Fix-up round 2 — F1 + F2 + F4
Scope from the at-tier review record (PR comment
5672172206) and the seat's rulings(card comment
5672187836): F1 + F2 + F4, nothing else. Run insession_01KB5PFtxuy1x3dcR5gxudx6. Headae337c01a9. The LEVEL of the changeset isuntouched —
minoris ruled.Verdict on the rescue commit
1974dd56dc— right to act, wrong on the fact; supersededThat commit (seat-made, marked INCOMPLETE AND UNREVIEWED) rewrote the justification to read
"the string-arm closing on this same schema (#16322) declared
"@objectstack/spec": patch".It was right that the original sentence ("the same grade every other accept-set narrowing on
this schema has taken") sourced nothing. Its replacement attribution does not hold, measured
on this tree:
.changeset/analytics-daterange-driver-alignment.md(Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322) is still in.changeset/,unreleased, and describes its own spec entry as "a
PROVENANCE_WAIVERSrow only" — thedriver half of
driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041, not an accept-set narrowing at the schema.driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041, and it shipped"@objectstack/spec": minor—packages/spec/CHANGELOG.md17.4.0, commit233222e, under### Minor Changes(heading at :5, entry at :60, no intervening heading).So the review's F4 comparison was drawn against the wrong entry, and repeating it would have
compiled a second inaccurate justification into the shipped CHANGELOG — the exact defect F4
names. The sentence now names #16041, its grade and where to read it, and disposes of #16322
explicitly so the next reader does not re-litigate it from the same mis-reading. The ruling is
executed (the justification is corrected), not reopened (the level is not touched).
F1 — a sentence that ships to npm was false
Door measurement taken here, not adopted from the review.
datasetSelectionRefusaldrivendirectly over the built dist:
dateRange['2026-01-01']ANALYTICS_DATE_RANGE_UNRECOGNIZED… received a 1-element array, not the two bounds [start, end]. Refused at the schema (…)[]… received an empty array … Refused at the schema (…)['a','b','c']… received a 3-element array … Refused at the schema (…)['2026-01-01','2026-01-31']undefined)Mechanism confirmed in source:
analytics-selection-door.tsparses the selection's sharedmembers —
timeDimensionsincluded — againstAnalyticsQuerySchema.pick(…), andrest-server.ts:10981calls it beforequeryDataset. ⇒ the one route the TSDoc named as a"past the door" origin is a schema-origin door.
Corrected at four sites, all naming the in-process
AnalyticsService.query/queryDataset/dataset-executor path as the runtime origin and stating that every REST analytics route is a
schema-origin door:
packages/spec/src/data/analytics.zod.ts— the shipped one (files[]carriessrc/**/*.zod.tsliterally).packages/core/src/utils/analytics-date-range.tsReachability — pre-existing on the base,carried per the ruling.
packages/core/src/utils/analytics-date-range.ts"Every refusal raised here is raised PASTthe schema door" — this PR's own new prose, which the review flagged as untrue of the REST
dataset door. Narrowed to refusals whose MESSAGE leaves the constructor, with the one caller
that keeps only
.code/.statusnamed.service-analyticsobjectql-daterange.test.tscomment.F2 — one condition, two wordings on the wire
Premise check first (falsifiable, and it holds): on the base the arm was
z.array(z.string())with no length constraint, so a 1-element array was not refused by theschema at all. No schema refusal, no second entry ⇒ this PR introduces the second wording, and
it is this PR's to collapse.
Wire measurement, before: on the dataset door,
['2026-01-01']servedcode: ANALYTICS_DATE_RANGE_UNRECOGNIZEDwith a message ending… Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).; selection.timeDimensions.0.dateRange: Too small: expected array to have >=2 items— the prescription, then zod's restatement of the very arity it prescribes. A body wrong in
more than the dateRange carried the same pair as two
fields[]entries, so the defect was notconfined to the lifted path.
Wire measurement, after (same probe, same tree):
Too small/Too bigoccurrences = 0for
['2026-01-01'],[]and['a','b','c']; prescription occurrences = 1; the legal windowand the preset still pass; and the mixed body still carries
selection.measuresbeside exactlyone
selection.timeDimensions.0.dateRangeentry.Where the collapse lives, and why there. In
fieldsFromZodIssues(
packages/types/src/validation-failure.ts), keyed on the exported structural recogniserisAnalyticsDateRangeRefusalIssue. Both REST doors report through that one mapper — the runtime/analytics/querydoor already did; the dataset door now does too, instead of reading the rawD3
zodIssuesToFields— so the rule has ONE implementation. The alternatives were each worse:a copy at both doors is the two-copies-of-one-policy shape #8124/#8318 exist to end; the
selection policy in
shared/union-branch-policy.tsis shared with the terminal renderer andpinned against it by a parity test, so a rule added there would have to be schema-agnostic; and
a schema-agnostic rule ("drop branch issues at the union's own path") silences the
#4001unknown-key prescriptions, which are raised at exactly that position. A new exported helper was
rejected too: T3 of
check-widening-tells.mjsreads a newapi-surfacerow as a widening tell,and this PR's claim declares
Clause-②: no.What is deliberately NOT collapsed — this narrows a restatement, never a diagnosis:
['2026-01-01', 3]still reportsselection.timeDimensions.0.dateRange.1— WHICH bound is not a string is a location theprescription does not carry.
wholesale, so the stop condition in the ruling was not reached.
Pinned on the wire, in both directions (the review recorded that nothing pinned it either
way): 4 cases at the dataset door, 4 at
/analytics/query, 5 in the shared mapper's own suite —including a CONTROL that a branch issue at its own branch root (
unrecognized_keys, the samestructural position) still reaches the wire for every other key.
@objectstack/typesand@objectstack/restjoin the changeset: both publish a changed wire.Verification on
ae337c01a9scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack,re-derived on the final head): 113 of 115 exit 0.
PREREQUISITE NOT MET, exit 3, printing "⛔ This is NOT a pass: nothing wasmeasured" —
check:dual-build-cjs-loadsandcheck:type-check-debt, both of which need thewhole monorepo built. NOT MEASURED, not red; CI builds fresh.
check:skill-examplesrefused the same way at first and was cleared by buildingclient-react/client: 258 examples over 3 surfaces, exit 0.pnpm lint(repo-wide,eslint . --no-inline-config): exit 0 — the whole population, so nonarrowing to justify.
service-analytics 2392 · core 1316 · types 671.
typecheckexit 0: spec, core, types, rest, runtime;service-analytics tsc --noEmitexit 0with 0 errors (the review saw 38 there, all from unbuilt workspace deps in its worktree).
check:type-source-resolutionexit 0 (135 tsc programs, 80 packages) — the new@objectstack/spec/dataimport inpackages/typesis a fifth subpath on a dependency thatpackage already declares.
dispatch-gatesprints a STALE TREE warning: its one stale input isscripts/pm/check-harness-current.mjs, added onmainafter this branch, whose own headersays "⛔ not a CI gate" — so it adds no family here.
Acceptance notes (noted, not filed)
packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts:32carries the same "which types its selection from
AnalyticsQueryand NEVER Zod-parses it"sentence about
POST /analytics/dataset/query. It is pre-existing (not in this diff) and theruling scoped this round to F1 + F2 + F4 naming three sites, so it is left alone. Not shipped
prose — a test comment. Carrier: the next PR in this family, or [finding] after #18230, service-analytics keeps a second refusal wording whose only stated justification is gone — the
#5240one-condition-one-wording convention #18232.Generated by Claude Code