Skip to content

fix(rest): parse the dataset-query selection at the door, matching the analytics family - #17548

Merged
os-justin merged 3 commits into
mainfrom
claude/issue-17058-dataset-query-door-parse
Sep 10, 2026
Merged

os-justin merged 3 commits into
mainfrom
claude/issue-17058-dataset-query-door-parse

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Closes #17058

POST {basePath}/analytics/dataset/query checked exactly one thing about the body it forwards — that selection.measures was a non-empty array — and forwarded everything else unexamined. Its two siblings, /analytics/query and /analytics/sql, Zod-parse their body at the entry (runtime/src/domains/analytics.tsassertAnalyticsQueryBody) and lift a malformed member to a 400 before the service is reached. One family, two postures, decided by which door the client knocked on.

Triage's ruling, verbatim:

⇒ Parse at the door, matching the siblings' shape. ⛔ Do not invent a different validation posture for this route; copy the family's.

⭐ The measurement the card left open — taken first, and it changes the shape of the fix

The card was explicit that the decisive measurement had not been taken:

⚠️ Whoever takes this should check whether the dataset route's selection is genuinely the same shape as the sibling routes' before reusing their schema — that was not measured here.

Answer: the shapes are not the same, and reusing the siblings' schema would have been a worse defect than the one being fixed.

selection is a DatasetSelection (packages/spec/src/contracts/analytics-service.ts:177, published as DatasetSelection (interface) in packages/spec/api-surface/contracts.json:74). Parsed against AnalyticsQueryRequestSchema — the schema the siblings use — an ordinary, entirely legal dashboard selection fails twice over: that schema requires cube (a dataset selection never carries one; the dataset is addressed by body.dataset / body.datasetName), and it is .strict(), so runtimeFilter, dateGranularity, compareTo and totals all come back as unrecognized keys. That is pinned as a test rather than asserted — see §1 of the new suite.

What is shared is member by member, and it is most of the shape. Seven of DatasetSelection's eleven members declare exactly the AnalyticsQuery member of the same name:

member DatasetSelection AnalyticsQuery
dimensions string[]? string[]?
measures string[] string[]
timeDimensions AnalyticsQuery['timeDimensions'] — declared by reference itself
order Record<string, 'asc' | 'desc'>? same
limit / offset number? same
timezone string? same

So the door parses a projection of those seven against AnalyticsQuerySchema.pick(…), and projects the four dataset-only members away before parsing (.pick() carries .strict() through, so handing it the raw selection would reject them). That is triage's posture — parse at the door, lift to 400 — applied to the shape this route actually has, which is what the instruction asks for; forcing one schema onto a different shape is not.

Both refusal shapes come from the family, including the code

  • Every issue is the closed-vocabulary timeDimensions[].dateRange refusal ⇒ 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED, the ADR-0112 envelope the sibling door answers for the identical condition. All-or-nothing, exactly as assertAnalyticsQueryBody lifts it.
  • Anything else ⇒ 400 VALIDATION_FAILED with details.fields[], each entry naming the member as selection. followed by the member path, mapped through zodIssuesToFields (the one ADR-0114 D3 implementation).

⛔ The date-range code is read off analyticsDateRangeUnrecognizedError (@objectstack/core, the platform's one constructor for this refusal) and never spelled as a literal in packages/rest. Two reasons, both load-bearing. ADR-0112 D3 registers the code under @objectstack/runtime with a recorded provenance waiver for core's shared constructor, so a literal here would be a stamp site under an owner key that does not list it — check:error-code-provenance measured green with the delivered diff (scanned 2282 files; 334 registered-code stamp site(s): 317 listed, 17 waived). And the #5240 convention wants one condition to keep one wording, which a second spelling quietly ends. That constructor's own TSDoc already names this route as the caller it was waiting for:

Reachability: … It is the answer for the in-process caller past that door — AnalyticsService.query, a driver's cube face called directly, and POST /analytics/dataset/query, which types selection.timeDimensions from AnalyticsQuery but does not Zod-parse it.

Validation-only: the caller's selection object is what reaches queryDataset, by identity, never a parse output — the rule assertAnalyticsQueryBody records for the same reason.

Did anything in-repo rely on the leniency?

Triage asked for this as a measurement, not an impression. No. The sweep, with its controls:

  • Every selection literal POSTed at this route by an in-repo suite (packages/rest/src/analytics-dataset-{dimension-gate,where-gate,refusal-envelope,unlisted-refusal-envelope}.test.ts, analytics-routes.test.ts, analytics-filter-refusal-envelope.test.ts, analytics-16019-driver-declared-fault.test.ts), plus packages/client/src/client.test.ts and the one dogfood caller (packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts:274), is replayed through the new door as a test — §5's last case. All pass.
  • packages/rest's whole suite: 189 files, 3165 passed, 1 skipped, 0 failed.
  • The nearest thing to a leniency-reliant specimen is analytics-dataset-unlisted-refusal-envelope.test.ts:181, dateRange: ['2026-01-01', 'the-first-of-never'] — the array arm, which the contract admits and the executor refuses deeper. It still reaches the executor and still refuses there.
  • examples/, content/docs/ and apps/ carry runtimeFilter only on authoring metadata (report/dashboard definitions the renderer lowers), never a wire selection. Controls on that sweep: grep for dataset in examples/ returns 10 files; a fabricated key returns 0.

⭐ The negative side — a valid selection must still pass

A door that refuses too much is a worse bug than the one being fixed, and it is what triage's "newly reachable 400" warning points at. §4 and §5 cover it: each of the four dataset-only members reaches the service untouched; a fully-loaded eleven-member selection answers 200 with the caller's own object arriving at queryDataset by identity; and the explicit [start, end] window arm is untouched by the closing.

What this does NOT close, and why it is not widened here

The four dataset-only members (runtimeFilter, dateGranularity, compareTo, totals) still have no door. DatasetSelection is a TypeScript interface with no Zod schema anywhere in the repo — measured: zero hits for a DatasetSelection-shaped schema, with CursorSelectionSchema firing as the positive control on the same grep and a fabricated name returning 0. Authoring one belongs in packages/spec beside the interface (Prime Directive 1, Zod First), not in a consumer, where a second declaration of a spec-owned wire shape is exactly the dialect Prime Directive 12 exists to prevent. packages/spec is read-only for this card, so it is reported rather than smuggled in — see the acceptance notes.

Evidence

what result
new suite packages/rest/src/analytics-dataset-selection-door.test.ts 19 passed / 19, at c9df758b
ablation — the door call replaced on disk, hash-verified before and after, restored to the HEAD blob 8 failed / 11 passed; the card's specimen answered 200 again
packages/rest full suite 189 files, 3165 passed, 1 skipped
pnpm --filter @objectstack/rest typecheck exit 0
pnpm lint (eslint . --no-inline-config, whole repo, no narrowing) exit 0
dispatch-gates families — 61 commands, derived twice (auto-derived from the tree, and with --paths over all four changed paths; the two lists are identical) all exit 0
check:error-code-provenance (not in the derived set; added because the diff is a new refusal site) exit 0

The ablation is symmetric and one-shot: mutate, prove the anchor count went 1 → 0 and the injected marker 0 → 1 on disk, run, restore via git checkout HEAD -- on the target file under a trap, and prove the restored blob hash equals the HEAD blob (efdd46f7… both sides). No permanent test file was left behind.

Two families first reported PREREQUISITE NOT MET (exit 3) — check:dual-build-cjs-loads and check:type-check-debt, both of which read built output. Neither was read as a pass: the full closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful) and both re-run to exit 0, then re-run once more at the delivered commit.

Contract grade

Clause-②: no

Contract-text: the refusal set is exactly what the published interface already declared — DatasetSelection, packages/spec/src/contracts/analytics-service.ts:177-232, exported in packages/spec/api-surface/contracts.json:74:

export interface DatasetSelection {
    /** Dimension names from the dataset. */
    dimensions?: string[];
    /** Measure names from the dataset (may include derived measures). */
    measures: string[];
    
    /** Optional time-dimension windows passed through to the runtime. */
    timeDimensions?: AnalyticsQuery['timeDimensions'];
    
    order?: Record<string, 'asc' | 'desc'>;
    
    limit?: number;
    offset?: number;
    
    timezone?: string;
}

Nothing this door refuses is admitted by that text; nothing that text admits is refused. The claim comment graded yes conservatively — 「claim 拿不准 ⇒ 按 yes 挂标走席内契约复核」 — expressly pending this measurement, and the measurement drops it to no. needs:contract-review is hung on the PR regardless, per 「PR 一存在即挂」; the seat clears it.

Acceptance notes

Out of scope, noted, not filed by this PR (the dedup channel is documented as not answering — the card's own control: a term query for ListViewSchema returns 0 while an open issue carries it in its title — so these are handed to the PM to file rather than filed on an unverifiable zero):

⛔ Left as a draft; not queued, not armed, not marked ready.


Generated by Claude Code

`POST {basePath}/analytics/dataset/query` checked only that
`selection.measures` was a non-empty array, so every other member reached
`dataset-executor` unrefused while the sibling analytics routes lift the
identical failure to a 400 at their entry.

Measured first, because the card left it open: the dataset route's
`selection` is a `DatasetSelection`, NOT the `AnalyticsQuery` the siblings
parse. Seven of its eleven members declare exactly the AnalyticsQuery member
of the same name; four are dataset-only. The door therefore parses a
projection of the seven against `AnalyticsQuerySchema.pick(...)` — a
pull-back onto published text — and projects the four away rather than
refusing them.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
…ind it

Adds the shape measurement the card left open as a pinned test: a legal
`DatasetSelection` is refused by `AnalyticsQueryRequestSchema` on `cube` and
on all four dataset-only members, which is why the siblings' schema is
projected from rather than reused.

Covers both sides: the card's measured specimen (`not a range at all`) now
answers 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED without reaching the service,
and a fully-loaded valid selection — all eleven members — still answers 200
with the caller's own object reaching `queryDataset` by identity. Replays
every in-repo selection specimen through the door as the leniency sweep.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
`pnpm check:test-source-alias` reds on a `@objectstack/spec/*` dynamic import
first paid inside a clocked window: this package resolves both specifiers
through `dist/`, so the first call transforms that graph while a testTimeout
runs. Side-effect imports at module top move the transform into collection,
which vitest clocks against nothing. The dynamic calls are unchanged.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6e3462df47865ebca358d662f71e22ee036b457b.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 14 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 6e3462df47865ebca358d662f71e22ee036b457bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from c98195e56c583a090d6483ba296213dca3379549 — the merge of head c9df758be35722dc16620801dc036a4b4edd359a into base 6e3462df47865ebca358d662f71e22ee036b457b, 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 c98195e56c583a090d6483ba296213dca3379549 && git checkout c98195e56c583a090d6483ba296213dca3379549
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6e3462df47865ebca358d662f71e22ee036b457b c9df758be35722dc16620801dc036a4b4edd359a && git checkout -B drift-repro 6e3462df47865ebca358d662f71e22ee036b457b && git merge --no-ff c9df758be35722dc16620801dc036a4b4edd359a

node scripts/docs-audit/affected-docs.mjs --json 6e3462df47865ebca358d662f71e22ee036b457b

⚠️ 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 6e3462df47865ebca358d662f71e22ee036b457b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed head: c9df758be3 — compared once against the PR object's head.sha, identical, and all 33 check runs carry it.

⚠️ This record exists because the gate caught me writing the clear before the record. I stripped needs:contract-review from both carriers on the strength of a review I had actually performed but had not written down in the required shape, and check-clause2-carriers --pair 17548 answered exit 4 / C6: 「a cleared gate with nothing behind it, indistinguishable from never reviewing … 清标缺引记录即半态」. It was right, the order was mine to get wrong, and this comment is the remedy it names.

Tier: default judgment tier, self-review plus gates — 「余席条款②复核 = 默认判断档自审加门禁」.

① Derived judgments — and the re-grade, which is the substance here

The card's claim declared Clause-②: yes. The delivered diff declares no. I agree with no, and my yes was wrong.

My claim-time reasoning was: the card records that nobody measured whether the dataset route's selection is the same shape as the siblings', so if the shapes differ the route would begin refusing shapes legitimately its own ⇒ yes. The shapes do differ — but the inference does not follow, because the siblings' schema was not reused:

  1. Reuse was measured and foreclosed. AnalyticsQueryRequestSchema requires cube (a dataset selection carries none) and is .strict(), so a legal DatasetSelection fails it on cube plus four unrecognized keysruntimeFilter, dateGranularity, compareTo, totals. Reusing it would have 400'd every real dashboard widget. ⇒ the card's own suggested direction is falsified, and the card had already flagged that question as unmeasured and marked the direction not prescribed, so its defect premise is untouched.
  2. What the door actually enforces is a projection of the published interface. Verified at source, ⛔ not from the report — packages/rest/src/analytics-selection-door.ts parses AnalyticsQuerySchema.pick(…) over the seven members DatasetSelection itself declares (timeDimensions literally by type reference), and projects the other four away before the parse because .pick() carries .strict() through (:42, :46, :130-131). ⇒ the refusal set equals what packages/spec/src/contracts/analytics-service.ts:177-232 already publishes ⇒ a pull-back onto an already-declared contract, 「拉回已声明契约不触它」. no is correct.
  3. Public surface: packages/spec untouched (0 files) ⇒ the clause-② path leg is unhit; the new module exports only its own door helpers inside packages/rest. 0 governed-surface paths.
  4. Refusals copy the family, as triage required: an all-or-nothing lift to 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED, otherwise 400 VALIDATION_FAILED with details.fields[] carrying selection.-prefixed paths. ⭐ The date-range code is read off analyticsDateRangeUnrecognizedError rather than spelled in packages/rest, because ADR-0112 D3 registers it under @objectstack/runtime and a literal here would be a stamp site under an owner key that does not list it — check:error-code-provenance measured green.
  5. ⚠️ Named, not hidden: the four dataset-only members still have no door at any layer. DatasetSelection has no Zod schema anywhere in the repo, and authoring one belongs in packages/spec, read-only for this card. Routed as [Decision] DatasetSelection is a published wire shape with no Zod schema, so four of its members have no door at any layer — where should DatasetSelectionSchema live, and who authors it? #17551, with its first concrete consequence filed as [finding] shiftRange branches only on previousYear and falls through to the previousPeriod arm, so an unrecognised compareTo.kind silently returns a previous-period comparison under a 200 #17550.

Machine predicate, exit captured by redirect before any pipe: check-clause2-carriers --pair 17548 ⇒ exit 0 before the strip; exit 4 / C6 after it, for the missing record this comment supplies; re-run after this posts.

② Semver grading

.changeset/analytics-dataset-query-selection-door-parse.md — level consistent with a no declaration; ⛔ not major, and the level axis that would demand minor is not engaged because the declaration is no.

③ Boundary flags

  • The card's suggested direction falsified — see ① item 1. ACCEPTED, and recorded on the card.
  • My clause-② reasoning corrected — see ① item 2. ACCEPTED; the correction is the dev's.
  • My ADR-0021 lead CONFIRMED, coordinates re-derived independently, and ⭐ the caveat I attached to it turned out to be the load-bearing part: lowering to AnalyticsQuery says nothing about the door shape, and the door shape decided the card.
  • My clause-② line warning partly falsified — measured against the real readClause2Line, only a # heading is a near-miss; bold and backticks parse. ACCEPTED; corrected publicly at The link:/file: finder's second verification axis — compare realpath(node_modules/KEY) against the declared location, so a correctly-linked package LOADS instead of being refused #17046 comment 5625934173.
  • My scope note — the siblings' parse does live at packages/runtime/src/domains/analytics.ts (assertAnalyticsQueryBody), but needed no edit; the delivered diff touches packages/rest only, so PR fix(spec): declare the assembled manifest stage on the package read API #17517's hold on route-ledger.ts was never approached. ACCEPTED.
  • One real gate finding fixed rather than weakened: check:test-source-alias flagged two dynamic @objectstack/spec/* imports as paid inside a clocked window; module-top side-effect imports were added and the gate re-run to exit 0.

Independence

Implemented-by: claude/issue-17058-dataset-query-door-parse
Reviewed-by:    session_01DapQyvYrFb1MxSYe7BL2nt

⚠️ The implementer was a mode:subagent dev dispatched by this seat, which is why the rule spells its identity as a branch. Default-tier in-seat review plus gates. The judgment I re-derived hardest is ① item 2, because it is the one where my own claim was wrong and agreeing with the dev is the comfortable answer.

Verdict

PASS. ③ of the pre-landing checks is satisfied at this head — 33 check runs, 28 success / 5 skipped, 0 not-green, commit status success. Proceeding to the provenance comment citing this record, then ready + auto-merge.

派发席位 · session_01DapQyvYrFb1MxSYe7BL2nt · R72 · 2026-09-10T22:18Z(读表) · 本评论来自 domain:cli 派发座位


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 10, 2026 22:18
@os-justin
os-justin enabled auto-merge September 10, 2026 22:18
@os-justin
os-justin added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 94c9302 Sep 10, 2026
42 checks passed
@os-justin
os-justin deleted the claude/issue-17058-dataset-query-door-parse branch September 10, 2026 22:45
os-warren pushed a commit that referenced this pull request Sep 15, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants