feat(spec): author DatasetSelectionSchema and parse the whole selection at the analytics dataset door - #19638
Conversation
…ntracts Ruled on #17551 (letter A): the four undoored members become refusable. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Measured against the sibling route's own schema: identical posture on all seven spellings, so the dataset route is no longer the looser of the two. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 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 a7171b6f8f84de3024f24ac91daebdb536cf0003 && git checkout a7171b6f8f84de3024f24ac91daebdb536cf0003
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1912237481125d0d6223ae9326bff621d60327a5 9b7fcc36e44569454070aa0cfccaaf6434eaf1a7 && git checkout -B drift-repro 1912237481125d0d6223ae9326bff621d60327a5 && git merge --no-ff 9b7fcc36e44569454070aa0cfccaaf6434eaf1a7
node scripts/docs-audit/affected-docs.mjs --json 1912237481125d0d6223ae9326bff621d60327a5
|
…ed window `check:test-source-alias` — the CONTROL cases added for #17551 reach `@objectstack/spec/api` through a dynamic import inside a test body, and this package resolves that specifier through `dist/`. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
`check:issue-citations` — the card that round closed is no longer on this board (`allocated-but-absent`; deleted vs transferred NOT MEASURED), so every site this change touches names PR #17548 instead, and the door's header says so in prose. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 205/205
① Derived judgmentsDetached worktrees cut from the head sha and merge-base 2. "By reference" — VERIFIED in the strongest available form: object identity. 3. No second declaration — zero, with a firing control. Target regex over both repo trees returns 6 hits, all in 4. Zod-on-the-import-path — VERIFIED, the reason is not hollow. All nine imports in 5. Single builder — genuinely ONE sentence. 6. What newly refuses — measured, and it is a pull-back, not a narrowing past published text. The old door was reconstructed exactly (project the seven, parse against 7. Cross-repo — re-measured at the PIN; nothing breaks. 8. #17550 as a refusal test — VERIFIED as claimed. §6 drives the card's own specimen through the real route: 400, 9. Generated artefacts — fresh, and measured against a dist the reviewer built. Tests run first-hand: spec 10. CI at this head — by job conclusion, 35 runs, 35 distinct names. 33 ② Semver levelChangeset: What grew. The Which side Which side it cannot. ⛔ It CANNOT fail on which package received the widening — in the script's own words, "Clause ② is declared ONCE, FOR THE PR". And it cannot fail on the narrowing at all: during the launch window the bump level is explicitly not the carrier for breaking-ness, and Making it. The narrowing is real on the wire — fifteen measured specimens — but every one was already refused by the published interface, and each is refused identically by the sibling route. The repo's standard for a breaking narrowing is a narrowing past published text; this is a pull-back onto it. The direct precedent settles it: PR #17548 performed the same act on the same route one round earlier, graded One inconsistency, immaterial to what ships: that predecessor graded ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Carrier stripped on BOTH carriers, citing the record — the four landing preconditions, readSeat The record it is stripped against: PR comment
Governed-surface reading, derived not recalled:
Proceeding to ready and auto-merge. ⛔ Nothing here approves the PR and ⛔ nothing merges it by hand — the queue does that. Generated by Claude Code |
Fixes #17551
Clause-②: yes
Ruling-ref: 5754491527— decision batch #204 item 3, letter A, maintainer 「204 同意」 2026-09-21T02:08Z. Governing text:docs/NORTH-STAR.md〈优先级〉4 「错的必须被响亮拒绝并给处方,永不静默落库」. ⛔ This PR executes that ruling; it does not reopen it. Option C (a second declaration inpackages/rest) was refused by name and is not taken.What was wrong
DatasetSelectionwas a TypeScript interface with no Zod schema anywhere in the repo. PR #17548 (card #17058) dooredPOST /api/v1/analytics/dataset/query, but only over the seven members the selection shares withAnalyticsQuery— the other four (runtimeFilter,dateGranularity,compareTo,totals) were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. #17550 is the measured consequence:compareTo: { kind: 'nonsense' }returned a previous-period comparison under an ordinary 200.The placement, and why
packages/spec/src/api/analytics.zod.ts. The ruling left the choice between that file andcontracts/analytics-service.tsto this seat, ONE place. Three readings decided it:DatasetSelection's own published text calls it 「the wire shape a preview/query endpoint posts」.api/analytics.zod.tsis 「the HTTP interface for the Semantic Layer」 and already holdsAnalyticsQueryRequestSchema, the sibling routes' request body. One file now holds the analytics family's request-body declarations.contracts/analytics-service.tsis type-only for this service — every one of its analytics imports isimport type. A Zod value there would putzodon the import path of anything that imports@objectstack/spec/contractsfor types alone.AnalyticsQuerySchema.shape. The door used to carry that agreement as a hand-written array — a standing claim that two declarations matched. Taking the declarations themselves makes it structural: there is no second copy left to drift, and a member that leavesAnalyticsQueryfails the build rather than becoming a private dialect.⇒
@objectstack/spec/contractsre-exports theDatasetSelection/DatasetCompareTotypes from the schema instead of declaring interfaces beside it — the moveAnalyticsQueryalready made in that same file (#4538), taken here before a mirror could drift rather than after. ⛔ There is no second declaration anywhere.The door's parse point
packages/rest/src/rest-server.ts→datasetSelectionRefusal(selection), unchanged in position: after the route's ownselection.measurescheck and beforequeryDatasetis called. What changed is what it parses — the whole selection againstDatasetSelectionSchema, where it used to parse a projection of seven members. The envelope is unchanged (400 VALIDATION_FAILED+details.fields[], or the family'sANALYTICS_DATE_RANGE_UNRECOGNIZEDwhen every issue is that condition), and the route still forwards the caller's object to the service by identity, never a parse output..strict()parse puts an unrecognized-keys issue at the ROOT, which the mapper spells(body)— true for the sibling routes, false here — so the root is re-spelledselection.The refusal messages, and their remedies
compareTo.kindoutside the closed pair'previousPeriod'/'previousYear', each described), 「Name one of those, or drop compareTo」, where it was refused, and that an unrecognised spelling used to answer a comparison under a 200compareTo.offset(retired in #5011){ offset: '1y' }is exactlypreviousYear; for any other duration, state the window on thetimeDimensions[]entry and compare withpreviousPeriodcompareTo: 'previousPeriod'(the bare-string form)compareTo: { kind: 'previousPeriod' }; when to adddimensionwhere→runtimeFilter,orderBy→order,granularity→dateGranularity,values→measures)cube/dataset/datasetName/previewDraftsselectiontotals: { dimensions: […] }dimensionsis how a total is reported back; ask for it astotals: { groupings: [[…], []] }dateGranularityoutside the vocabularyruntimeFilterFilterCondition, so its refusals are that vocabulary's own — byte-identical to what the sibling body'swhereanswers. A second wording here would be exactly the defect thecompareTo.kindbuilder avoids.⭐
datasetCompareKindRefusalMessage(input, origin)is ONE builder for ONE condition, on theanalyticsDateRangeRefusalMessagepattern: the schema door raises it with'schema', and@objectstack/service-analytics'shiftRange— a published export reachable in-process by a caller that never posted a body — raises the same sentence with'runtime'. The two differ only in the clause that says where the refusal happened, which is the one clause no input can supply. The executor's own refusal is not made redundant and is not removed.#17550 as a refusal test
packages/rest/src/analytics-dataset-selection-door.test.ts§6 drives that card's own specimen through the real route: 400,selection.compareTo.kindindetails.fields[], the message carrying"nonsense", both legal kinds and 「drop compareTo」, theApiErrorSchemaenvelope check,queryDatasetnever called, a CONTROL that the same selection with a declared kind still answers 200 with the caller's object by identity, and a pin that the door's sentence is the builder's.shiftRangegained an exhaustiveswitch, ten days before the ruling was written. So this PR does not close it; it closes the half that fix could not reach, at the door, for the route it names. Nothing else in the execution list is affected.Generated artefacts
Regenerated with the repo's own tooling, ⛔ never by hand:
pnpm --filter @objectstack/spec buildthencheck:generated --fix, which proved 5 of 15 stale and regenerated exactly those —api-surface/,export-origins/,declaration-map/,content/docs/references/**, the strictness-ledger counts — then re-checked all five green.authorable-surface/andjson-schema.manifest/were written by the build's owngen:schema. Three hand-maintained ledgers moved with them and each is a decision, not a regeneration:dropped-refinements.baseline.jsongainsapi/DatasetSelection → runtimeFilter.lazy(the sameFilterConditionprojection gapapi/AnalyticsQueryRequest → where.lazyalready records) with its two header counts, andtype-alias-convention.pin.test.tsgains three isomorphic pins with its count.Acceptance notes
packages/spec/src/ui/dashboard.zod.ts's widgetcompareToand the newDatasetCompareToSchemadeclare the same two members with different prescriptions, deliberately: the widget's point at neighbouring widget keys (options.dateGranularity, the widget's ownfilter) that do not exist on a wire selection, and it carries authoring-time tombstones the wire never had. They share the vocabulary by construction —kind's refusal is the one builder — and nothing is filed.packages/core/src/utils/analytics-date-range.ts,packages/services/service-analytics/src/date-range-array-arm.tsandpackages/spec/src/data/analytics.zod.tseach carried a sentence asserting the dataset route parses 「the selection's shared members againstAnalyticsQuerySchema.pick(…)」. Each conclusion still holds and only the mechanism sentence was stale; all three are corrected in place. Comment-only, no publish surface.SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERYis deleted frompackages/rest. It was module-local with one consumer (its own test), and the claim it encoded is now pinned at the schema by identity.Validation
Every command's exit code was captured before any pipe.
pnpm --filter @objectstack/spec typechecktsc --noEmit+ scripts + test layerpnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec check:generated--fixpnpm --filter @objectstack/spec testpnpm --filter @objectstack/rest testpnpm --filter @objectstack/service-analytics testpnpm --filter @objectstack/core testpnpm --filter @objectstack/{spec,rest,service-analytics,core} typecheckpnpm check:spec-parsed-aliasThe gate family was derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst the real change set rather than from a list, and the derived commands were run and reconciled with--ran.Generated by Claude Code