fix(core, driver-memory): one reading of the dateRange array arm — an ARITY case in the conformance kit, and the cube face stops dropping the window - #17694
Conversation
The shared conformance kit had exactly one array-arm case — a two-element window — so the arity itself was governed nowhere and each face was free to invent a reading for `['2026-01-01']`, `[]`, `[a, b, c]` and `[null, null]`. Adds `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and the case that holds every REGISTERED face to the rule PR #17593 already landed on the service-analytics faces: a non-two-bound array is refused with the ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope. No existing case is weakened — the two-element window case is this one's control. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…window MEASURED end to end on `49cd71548`, four rows spanning 2020…2099: the cube face emitted NO time predicate at all for `['2026-01-01']`, `[]` and `['2026-01-01','2026-01-31','2026-02-01']` — a pipeline byte-identical to one with no `dateRange` — so the query read ALL of history, and `[null, null]` compared against the string `'null'` and selected nothing. The array arm is now judged at the discriminator and yields two bounds or throws the shared ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope, which is the rule the kit's new ARITY case holds every registered face to. The `if (range.length === 2)` guard — the line that dropped the window — is gone, and `ResolvedDateRange.bounds` is a tuple so it cannot come back. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Two bounds is necessary, not sufficient: calling `[null, null]` an arity problem sends the next reader to the wrong line. Surfaced by this change's own ablation, where a real window smuggled into the not-a-window population was reported as "every other arity". Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 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 475c1537ab7dfaec0fd29d6b99a133ed7d8310aa && git checkout 475c1537ab7dfaec0fd29d6b99a133ed7d8310aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88a933088e93067b4df4b20380ab9a1ceed2ed17 4ab57672f6fce3145a1e1248cec8c228b42a943f && git checkout -B drift-repro 88a933088e93067b4df4b20380ab9a1ceed2ed17 && git merge --no-ff 4ab57672f6fce3145a1e1248cec8c228b42a943f
node scripts/docs-audit/affected-docs.mjs --json 88a933088e93067b4df4b20380ab9a1ceed2ed17
|
A purely additive widening of a published package's public surface takes at least `minor` (maintainer ruling 2026-09-04, decision batch #35, on #15294): `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` is absent on `origin/main`, exported from `analytics-date-range-conformance.ts` and re-exported by the package entry. `@objectstack/driver-memory` stays `patch` and the changeset now says why: its public surface is byte-unchanged, and the behaviour it stops producing was a defect the contract never admitted. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead reviewed: ① Derived judgments
② Semver grading
③ Boundary flagsThe round's only open question — Three Independence pair
Verdict✅ PASSLanding pre-checks, all three by this seat: ① this comment · ② ⇒ Both carriers stripped in this same stroke.
Generated by Claude Code |
Fixes #17596
Clause-②: yes
Two halves, both in scope per the triage ruling on the card (
5628469889), which named the #5499 escalation fordriver-memory's window-construction seam — and only that seam.generateSql()(#17301) is untouched;packages/spec(#17598) is untouched.Half 2 first: the probe, because the card filed a SOURCE READING
The card said so in its own words, so the first act was driving the real face rather than reading it.
MemoryAnalyticsService.query, four rows spanning 2020…2099, one authored document, on49cd71548:dateRange['2026-01-01', '2026-01-01'](control)b_target— the one day$match+$group['2026-01-01']dateRangeat all[]['2026-01-01', '2026-01-31', '2026-02-01'][null, null]$gte: 'null', which no instant sorts insidedateRange(control)$grouponlyThe reading is confirmed, and the last column is the sharpest statement of it: for three shapes the face produced exactly the pipeline it produces when the caller asked for no time window at all. Nothing downstream — not a status, not a field, not the dump — could tell a silently widened dashboard from a correct one. That is the "plot all of history" shape #3650 was filed about, on the arm #16322 did not repair.
Half 1: the kit's ARITY case
analyticsDateRangeConformanceFindingshad exactly one array case — the two-element window — so the arity was governed nowhere. It now exportsANALYTICS_DATE_RANGE_NOT_A_WINDOW(four shapes: one element, empty, three elements, two null bounds) and holds every registered face to the rule PR #17593 already landed on the service-analytics faces: refused withANALYTICS_DATE_RANGE_UNRECOGNIZED/ 400. No second rule was invented.AnalyticsDateRangeFace.lowertakesstring | readonly unknown[].[]and[null, null]are shapes a real caller reaches a face with (POST /analytics/dataset/querytypes its selection fromAnalyticsQueryand never Zod-parses it). Existing runners need no change: the declaration is a method, so its parameter is bivariant.Order of operations — the case was made RED before the fix existed
Zone 3's order, followed, because adding the case and the fix in one motion leaves nobody able to tell whether the case would ever have caught anything.
1dd11a1bda—driver-memoryconformance: 8 findings, all from the new case, includingrefused ["2026-01-01"] with code undefined … (no window in the memory pipeline dump: … )andraised 2 different envelopes for one condition.service-analyticsconformance + its own arity pin: 30 passed. The rule already existed there, which is the evidence that the case asserts it rather than a second one.68a558e52f.The driver fix
The array arm is judged at the discriminator and either yields two bounds or throws the shared ADR-0112 envelope, built from
@objectstack/core's one constructor so thecode+statuspair keeps a single origin.if (range.length === 2)— the line that dropped the window — is gone, andResolvedDateRange.boundsis now a tuple, so it cannot come back as a silent drop. The message names what arrived, why it is not a window, the two-element contract and the single-day spelling to write instead.Verification
Every heavy run went through
scripts/pm/os-verify-lock.sh(slotissue-17596); verdict lines read, never a bare$?.Tests, on the final head:
@objectstack/driver-memoryfull suite: 53 files, 1242 tests, all passing (after the fix; the same suite carried the 8 findings before it).@objectstack/corefull suite: 51 files, 1311 tests, all passing.@objectstack/service-analyticsconformance + arity pin: 30 passing — the other registered face, unchanged and still green.typecheckon both touched packages: exit 0.memory-analytics-date-range-array-arity.test.ts— the ROW-level half the conformance runner cannot see, with its population imported from the kit so a shape added there gains a row-level reading here automatically. Its controls: a two-element window still selects exactly that day with driver-memory / driver-mongodb:裸日期$lte上界在 datetime 值上同样丢当天数据(#3777 的非 SQL 驱动对齐) #4042's half-open widening; a preset still resolves; and nodateRangestill selects all of history — so "refused" and "dropped" are distinguishable rather than both reading as green.Ablation — does the new case actually reach the OTHER registered face?
service-analyticsresolves@objectstack/corethrough itsexports, i.e.dist/, with no vitest alias back to source, so a green there is worthless until proven to run the new bytes. Both legs, from the committed state:1;@objectstack/corerebuilt;ablation-dist-preflight.mjs @objectstack/core '2044-01-01T00:00:00.000Z'exit 0 (marker present in the built artifact);service-analyticsconformance → 3 of 8 red, each naming the new case, one per face.git checkout HEAD --the kit; restored blob3b5f89d13a825240e3c9e5a375d6d188bdfc1baaequals the HEAD blob byte for byte;git diff HEADempty; rebuilt; preflight--absentexit 0 (marker gone from all 12 built files, tree clean); conformance → 8 passing.trap … EXIT INT TERMwith absolute paths throughout. No ablation artifact remains.The mutation leg also earned its keep: it showed the finding text calling
[null, null]an arity problem, which is the wrong half of the contract. Corrected in7fdb888aee.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(never a hand-written list) and reconciled with--ran: 60 derived, 57 run green, 3 NOT MEASURED —check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt, each exiting 3, PREREQUISITE NOT MET, all three because they read a whole-repodist/this worktree does not have. ⛔ Not read as passes. CI builds the closure and owns them. Ratchet-sensitive gates were re-run on the final head after the last commit:nul-bytes,type-check-coverage,cross-package-test-inputs,test-source-alias,published-files,driver-memory-census,driver-conformance, the three changeset gates andcomment-mask-adoption— all exit 0.Lint, narrowed and declared. ① Population: a repo-wide run lints 6634 files, read from eslint's own config via
lintFiles(['.']), not estimated. ② This run: 4 files — every lintable path in the diff — 0 errors, 0 warnings, exit 0, file count read from--format json. ③ Invariance: this repo runs oneeslint.config.mjswhich "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so this diff cannot move the verdict on any file it does not contain. The narrowing is therefore a measurement, not a skipped run.Clause-②
Clause-②: yes, by the mechanical floor: the diff adds a new exported symbol to a published package (ANALYTICS_DATE_RANGE_NOT_A_WINDOWfrom@objectstack/core, re-exported byexport *) and widens a published interface's parameter type.needs:contract-reviewis attached to both carriers.Changeset
@objectstack/core:minor— it gains a new exported symbol on its index (ANALYTICS_DATE_RANGE_NOT_A_WINDOW), and a purely additive widening of a published package's public surface takes at leastminorwhatever the commit type says (maintainer ruling 2026-09-04, decision batch #35, on #15294). Verified with a control: the symbol has 0 hits onorigin/mainwhere its siblingANALYTICS_DATE_RANGE_EXPLICIT_WINDOWhas 4.@objectstack/driver-memory:patch— its public surface is byte-unchanged (no new export;explicitDateRangeWindowis module-local, andsrc/index.tsis not in the diff). Its behaviour does change, from selecting every row to refusing with400 ANALYTICS_DATE_RANGE_UNRECOGNIZED, and that is apatchbecause the old behaviour was a defect and never a contract: the spec's own refusal wording already said an explicit window is the two-element array, and the #16322 migration table already told authors to write a single day as two bounds. The changeset carries the author-facing migration:['2026-01-01']becomes['2026-01-01', '2026-01-01'], which selected exactly that day before this change too.Acceptance notes
service-analytics'sdate-range-array-arm.tsanddriver-memory's newexplicitDateRangeWindownow implement the same arity-and-bounds judgement with their own package-specific sentences. That is the kit's declared architecture (the cases and rules live inpackages/core; each face's lowering lives in its own package), and unifying the implementation would have pulled a third package into a card triage measured as two. Noted, not filed. Carrier: the next card that adds or moves an analytics face — it will find both spellings through the kit's case.driver-sql/src/sql-driver.tsstill has nodateRangecode (comments only), andrest/src/analytics-selection-door.tsandruntime/src/domains/analytics.tsclassify the refusal rather than lowering a window. No sixth face, so no further package is owed the case.driver-memory's conformance runner used to throw a bare "no window in the memory pipeline dump" for a face that emitted no predicate at all. Since the kit now quotes that text whenever a thrown thing carries no ADR-0112code, it was reworded to name the consequence — every row selected — rather than the harness's disappointment.Generated by Claude Code