Skip to content

fix(analytics): a dateRange array that is not a two-bound window is refused once, instead of meaning three different things - #17593

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-17124-one-element-daterange
Sep 11, 2026
Merged

os-sales merged 4 commits into
mainfrom
claude/issue-17124-one-element-daterange

Conversation

@claude

@claude claude Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #17124

Clause-②: no — no exported symbol is added (the new rule is an internal module, absent from src/index.ts) and no key moves on a published payload; the change narrows rather than widens. needs:contract-review stays absent on both carriers.

The probe came first, and it reproduced — on FOUR faces, not two

The card's own hard constraint was to probe before repairing. Driven before a production line was touched, with ['2026-01-01'] over four rows (2020, 2026-01-01, 2026-06, 2099), on abc4b83ce:

face what ['2026-01-01'] meant
ObjectQLStrategy.dateRangeBounds {$gte: '2026-01-01', $lte: '2026-01-01'} — the point
NativeSQLStrategy no time clause emitted at all — the whole dataset
the draft-preview evaluator (end to end) selected 2026-01-01, 2026-06 and 2099 — unbounded above
DatasetExecutor.runCompare primary pass kept the one-element array; compare pass got the point ['2025-12-31','2025-12-31']

Control, in the same run: the two-element ['2026-01-01','2026-01-01'] selected exactly that one day on all four faces. So the divergence is specific to the shape, not to the harness.

⚠️ Two faces beyond the card's two. native-sql-strategy.ts was named only inside a sibling's comment, and dataset-executor.ts was named nowhere. The native-SQL reading is the dangerous one and it is worse than the card states: if (range.length === 2) was false, so no predicate was emitted and the query read all of history.

The arity, not the one element, is what the faces disagreed about — measured on the same run:

shape ObjectQL native SQL preview executor
['2026-01-01'] point window dropped unbounded above point (compare)
[] dropped dropped selects nothing bare TypeError
['2026-01-01','2026-01-31','2026-02-01'] first two dropped first two passed through
[null, null] dropped >= 'null' AND <= 'null' selects nothing bare TypeError

Two of those are a 500 for a malformed request, and [null,null] on the SQL face resurrects the literal [range, range] nonsense-point shape #16322 abolished.

The end chosen: refuse, in-lane — and why it is not an alignment

Triage's criterion is 一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑并删除. Applying it meant establishing what is governed rather than assuming it, and the answer is layered.

⚠️ Correction to my own first reading. I initially recorded that none of the three readings was governed. That is false, and a full package test run is what falsified it: objectql-daterange.test.ts carried a pin, 'narrows rather than vanishes on a one-entry dateRange array', asserting the point degeneration on the ObjectQL face — so that one reading did have a governance artifact, and this change turns it red. Stating it the other way round would have been the easy error, so here is what the artifact actually governs:

What is governed is the CONTRACT, and it already excludes the shape:

  • the spec's own single refusal wording, analyticsDateRangeRefusalMessage — the one sentence the schema door answers with — says verbatim: "an explicit window is the two-element array [start, end] of ISO dates or {date-macro} tokens";
  • the shipped Analytics drivers (memory, SQL) align dateRange string handling to the closed preset vocabulary (driver half of #16041) #16322 migration table (packages/spec/CHANGELOG.md, packages/runtime/CHANGELOG.md, .changeset/analytics-daterange-driver-alignment.md) tells authors to write a single day as ['2026-01-20', '2026-01-20'] — two bounds;
  • and the kit's own rule for a dateRange that resolves to no window is "an unresolvable window is a refusal, never a window". The refused-spellings list even carries '2026-01-20' annotated "the SQL single-day dialect, which is the ARRAY arm's job" — a single day as a string is already refused, on the ground that a window is what the array arm is for.

⇒ The arm's arity is declared; only the Zod type is weaker than the contract the same file states. So the criterion resolves with the contract as the governed side: all four faces rebind to it and their fallbacks are deleted. Not "teach every face the same guess", which the criterion forbids — one rule, explicitDateRangeWindow, four call sites, three divergent fallbacks removed.

Why refusal beats either alignment:

  1. Every alignment is silent. The author who wrote ['2026-01-01'] gets a number either way and cannot tell which of three meanings they got; the refusal is the only end where they learn the document is malformed.
  2. Aligning on the point reading would silently change the native-SQL face from "the whole dataset" to "one day" — a correctness change with no signal, on the face whose present answer is the dangerous one.
  3. The open-ended reading is argued against in-source: "drop the window" means "plot all of history", which is the very failure Analytics drivers (memory, SQL) align dateRange string handling to the closed preset vocabulary (driver half of #16041) #16322 repaired.
  4. ⭐ The author loses nothing. ['2026-01-01','2026-01-01'] already selects exactly that day on all four faces (the control above), so the refusal costs a second bound and buys a document that means one thing — and it is the spelling the shipped migration table already prescribes.
  5. No shipped producer emits the refused shapes: every in-tree dateRange array literal is two-element, and objectui's dashboard-level dateRange is an object, not this array.

The schema question, answered — and it forced NO route change

Stated plainly, as triage asked: this refuses at the service seam, not at the schema. AnalyticsDateRangeSchema (packages/spec/src/data/analytics.zod.ts:326) is untouched — that file is the domain:spec seat's and is not in this diff. The refusal lives where it is reachable: POST /analytics/dataset/query types its selection from AnalyticsQuery and never Zod-parses it, so the schema door is behind these faces, which is the same seam an unrecognised compareTo.kind is refused at.

⚠️ Residual, reported rather than fixed: the Zod type still admits a one-element array, so after this change the schema is strictly looser than every face. Tightening it narrows a published accept set at the contract and is the maintainer floor — a domain:spec decision, reported to the dispatching seat, not filed from here.

Scope: why the guard is arity-and-bound-type, not one element

explicitDateRangeWindow requires exactly two non-empty string bounds. That is the contract's sentence in full ("the two-element array [start, end] of ISO dates or {date-macro} tokens"), and it is one predicate on the one line each face already had; refusing only the one-element case would have left [], [a,b,c] and [null,null] splitting three ways on the same line, and [null,null]/[] throwing bare TypeErrors (500) through parseUTC. It deliberately stops short of validating bound FORMAT — a bare day versus a full timestamp is a per-face calendar translation (#3777 / #4042) and {date-macro} tokens are expanded upstream, neither of which an arity rule touches. An unparseable bound keeps its own DATASET_INVALID refusal (#5716): a different condition, so a different envelope.

One envelope for this condition across all four faces, asserted in the pin: ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400, built from the shared constructor so the code+status pair the conformance kit reads has a single origin. Only the sentence is this condition's own — the shared wording judges a bare string against the preset vocabulary and ends "Refused at the schema", and for an array refused past the schema door by a face both of those are false.

Verification

Pin — packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts, 22 assertions: the refusal on all four faces × four shapes, asserted on the ENVELOPE (code + status), never on toThrow() — [null,null]'s TypeError satisfied a bare toThrow; one-envelope-across-the-four; the message discipline; and four CONTROLS that the two-element window answers exactly as before on each face, including the #3777 half-open widening and the #16179 inclusive upper reading. Without those controls every assertion above is satisfiable by a face that refuses every array.

Test Files  1 passed (1)
      Tests  22 passed (22)

Ablation, on the committed state, with the pre-#17124 guess reinstated in the shared rule:

hash BEFORE mutation: 6930191ce33e02adfcd2add42e287fb46bfcc2e7
occurrences of anchor BEFORE: 1
occurrences of anchor AFTER : 0
occurrences of ABLATION marker AFTER: 1
hash AFTER mutation : cb81ea34eced0b419c01fa2818dd224cb63e17b7
RED leg vitest exit: 1
 Test Files  1 failed (1)
      Tests  17 failed | 5 passed (22)
--- RESTORE leg ---
git diff HEAD (must be EMPTY):        <empty>
git status --porcelain (must be EMPTY): <empty>
hash AFTER restore : 6930191ce33e02adfcd2add42e287fb46bfcc2e7
hash at HEAD       : 6930191ce33e02adfcd2add42e287fb46bfcc2e7
RESTORE OK: bytes identical to HEAD

The restore ran under a trap … EXIT INT TERM with absolute paths, git checkout HEAD -- <abs path> (never the bare form, which restores from the polluted index), and isevidenced by state — empty git diff HEAD and empty git status --porcelain plus the hash equality — not by an exit code. An empty hash was handled as FAILURE.

⭐ Measured, not assumed: no leg resolves through exports into dist/. The package's vitest.config.ts declares no alias (it exists for disableConsoleIntercept alone) and there is no root vitest config, and the pin reaches all four faces through relative specifiers (../analytics-service.js, ../preview-evaluator.js, ../dataset-executor.js), so they resolve to src/. The empirical proof is the ablation itself: the RED leg flipped with no rebuild between mutation and run, which only a src/-resolved import can do. The one exports-resolved import is @objectstack/core (for the shared refusal constructor), which is not a mutation target.

Gates

Derived from the ACTUAL diff with dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths vs merge base abc4b83ce), all run, reconciled with --ran:

✓ dispatch-gates --ran: 60 derived famil(ies) accounted for — 60 run,
  0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit code and none of them is 3)

Three of the 60 first came back exit 3 = PREREQUISITE NOT MET = NOT MEASURED, and were driven to a real 0 rather than rounded up: check:dual-build-cjs-loads and check:lean-entry-closure needed a built tree (turbo run build --filter='./packages/*' --filter='./packages/*/*' → VERDICT command-exit 0, 72/72 tasks), and check:type-check-debt then OOMed because my own NODE_OPTIONS=--max-old-space-size=4096 was below the 6144 MB CI-shaped ceiling the gate itself pins; re-run at its own ceiling it reports OK — 5 ledger entr(ies) re-measured in 69.4s, 55 raw tsc error(s) total, none above its recorded number.

Also run, because dispatch-gates scores them silent on a roster that lives in a directory one of my paths is in — where silence is evidence in neither direction: check-changeset-fixed (exit 0), check:authz-resolver (0), check:error-code-casing (0 — "no unlisted lowercase error codes in 6051 scanned file(s)"; this change adds no code, it reuses a registered one), check:filter-alias-parity (0), check:engine-double-contract (0).

Package suite and typecheck

os-verify-lock: VERDICT command-exit 0 · held the lock 69s (1m09s) · waited 0s
 Test Files  111 passed (111)
      Tests  2390 passed (2390)

tsc --noEmit clean, and measured to actually cover the new tests rather than assumed: --listFiles puts 134 files in the program, including both __tests__/date-range-array-arm-arity.test.ts and __tests__/objectql-daterange.test.ts and all five sources.

Lint — a proven narrowing, not a skip

pnpm lint's repo-wide eslint . --no-inline-config is CI's run. The narrowed run is a measurement, with all three pieces of evidence:

  1. Population, read from eslint's own config (via ESLint#isPathIgnored over git ls-files, not a guess): 6594 tracked files carry a lintable extension and the config ignores none of them ⇒ population 6594.
  2. File count, read from --format json: 7 files linted (every .ts in the diff), errorCount 0, warningCount 0, exit 0.
  3. Invariance over the 6587 untouched files: the config never enables type-aware linting for ANY file — no parserOptions.project, no typed @typescript-eslint rules — stated in eslint.config.mjs's own header and measured there with a positive control. ⇒ No edit in this diff can move the verdict on a file it did not touch.

All figures from the final commit, c8ec0f631.

docs-drift

Every row the bot names, answered:

row verdict
content/docs/releases/v14.mdx zero dateRange mentions — reached by one of the 7 symbol anchors, not by the date-range rule. Not stale. Release-owned; not edited.
content/docs/releases/v17/17-0.mdx:1377 states "timeDimensions[].dateRange is applied" (the #3650 fix). Still true — a two-element window is applied bound for bound; the line says nothing about arity. Not stale. Release-owned; not edited.

Emitter-blind hand search, for the page that states a date-range rule by its inputs and so shares no identifier with these faces: 5 non-release docs mention dateRange (control: 2 mention timeDimensions, so the instrument fires).

  • references/api/analytics.mdx:101 and references/data/analytics.mdx:113 already document the arm as "an explicit [start, end] array of ISO dates / {date-macro} tokens" — two bounds. This change enforces exactly what they already say, so they become more true, and both are AUTO-GENERATED — DO NOT EDIT from the Zod schema, which is unchanged.
  • ui/dashboards.mdx, references/ui/dashboard.mdx, deployment/validating-metadata.mdx are the dashboard-level dateRange object ({ field, defaultRange, allowCustomRange }) — a different key, not this array. Unaffected.

Fences

Re-verified, each with a firing control: PR #17454's path set is 37 paths with 0 under service-analytics (control: 31 of the 37 are under packages/, so the instrument fires). The nearest hold in this lane, #8345 (pm:on-hold), names only analytics-service.ts — not in this diff, which does not widen into it, so no intersection.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW


Generated by Claude Code

…o-bound window

The array arm of `AnalyticsDateRangeSchema` is a bare `z.array(z.string())`, so
`['2026-01-01']` is schema-valid and reaches the faces past the
`/analytics/dataset/query` door, which does not Zod-parse its selection. The four
faces in this package that read the arm answered it three different ways: the
ObjectQL strategy degenerated it to a point window, the native-SQL strategy
emitted no time clause at all and read all of history, the draft-preview
evaluator wrote `String(undefined)` as the upper bound (which every ISO date
sorts below), and the dataset executor's compare pass filled the missing bound
in from the lower one.

One rule, `explicitDateRangeWindow`, is now the single reading of the arm and all
four faces call it; the three divergent fallbacks are deleted. An array that is
not exactly two string bounds is refused with the ADR-0112
`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope, the same answer the contract
already gives for a `dateRange` that does not denote a window. The two-element
window is untouched on every face, bound for bound.

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

Drives every face that reads the dateRange array arm with the four shapes that
are not a two-bound window and asserts one ADR-0112 envelope, plus the control
that a two-element window still answers exactly as it did before on each face.

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

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 7 documentable anchor(s).

⛔ 2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class NativeSQLStrategy))
  • content/docs/releases/v17/17-0.mdx (via ObjectQLStrategy (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 9 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 29d00cc539c2822be79d4d728779312094b35525 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 29d00cc539c2822be79d4d728779312094b35525

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

…in with the refusal

`objectql-daterange.test.ts`'s 'narrows rather than vanishes on a one-entry
dateRange array' pinned exactly the branch this card retires. It chose the
narrower of two wrong answers because the alternative on the table was the
native-SQL face's silent drop to all of history; a refusal satisfies #3650's
intent strictly better, and the replacement keeps that card's own invariant by
asserting no unfiltered query reaches the engine. The retirement is the one the
neighbouring test declares deferred.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
@os-sales
os-sales marked this pull request as ready for review September 11, 2026 01:53
@os-sales
os-sales enabled auto-merge September 11, 2026 01:53
@os-sales
os-sales added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 86c5052 Sep 11, 2026
41 checks passed
@os-sales
os-sales deleted the claude/issue-17124-one-element-daterange branch September 11, 2026 02:12
os-warren pushed a commit that referenced this pull request Sep 14, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… ARITY case in the conformance kit, and the cube face stops dropping the window (objectstack-ai#17694)

* feat(core): hold every analytics face to the dateRange array ARITY

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 objectstack-ai#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>

* fix(driver-memory): refuse a dateRange array that is not a two-bound 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>

* chore(changeset): dateRange array arity — one reading on every face

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>

* refactor(core): the ARITY finding names which half of the contract broke

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>

* chore(changeset): grade @objectstack/core `minor` — it gains an export

A purely additive widening of a published package's public surface takes at
least `minor` (maintainer ruling 2026-09-04, decision batch objectstack-ai#35, on objectstack-ai#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>

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… each refusal origin gets a true sentence (objectstack-ai#18230)

Fixes objectstack-ai#17598

Ruling A (comment 5642584462, decision batch objectstack-ai#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 was `z.array(z.string())` with no
length constraint; it is now `z.tuple([z.string(), z.string()])`. A
tuple rather than `z.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 `error` mapper is unchanged in kind, so an arity refusal is
still ONE prescriptive `invalid_union` issue at
`timeDimensions.N.dateRange` and still carries the structural handle
`isAnalyticsDateRangeRefusalIssue` lifts into
`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — not the tuple arm's own
`too_big` / `too_small` text.

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, through `AnalyticsQuerySchema` and through the `POST
/analytics/query` body schema; the two-bound windows and every preset
name still parse; and the authored TYPE narrows too (a
`@ts-expect-error` case).

## 2. Each refusal ORIGIN gets a true sentence, with the origin as a
PARAMETER

`analyticsDateRangeRefusalMessage(input)` becomes
`analyticsDateRangeRefusalMessage(input, origin)`, `origin` being
`'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:

- "Refused at the schema" — now `'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 objectstack-ai#5240 one-condition-one-wording convention is
mechanical rather than asserted).
- "received an array with a non-string bound" — a new
`describeRefusedDateRange` names 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.md` declares
**`minor`**, not the `major` the ruling's execution note 1 names, and
says so in its own body. `scripts/check-changeset-no-major.mjs` refuses
a `major` outright: every publishable package is in the Changesets
`fixed` group, so one `major` promotes the whole stack, and during the
launch window this repo ships breaking changes as `minor`. 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/main` exit 0,
`check-adr-0087-registration.mjs --base origin/main` exit 0.

The ADR-0087 semantic entry is
`packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts`.
The `18.` prefix is the TARGET PROTOCOL MAJOR, not a sequence number:
`packages/spec` is at `17.4.0`, the sibling
`18.analytics-time-dimension-date-range-vocabulary-closed.ts` (objectstack-ai#16322,
the same schema's string arm) is filed the same way, and the generated
table lands it in `MIGRATIONS_BY_MAJOR[18].semantic` —
`check:migration-registry`, `check:spec-changes` and
`check:upgrade-guide` all 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.ts` plus 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, kept

It is **not** one of the four analytics faces PR objectstack-ai#17593 landed. Measured
from that PR's own file list: all 8 files are under
`packages/services/service-analytics/` plus its changeset;
`packages/core` is not among them. This file is the shared ADR-0112
envelope constructor `analyticsDateRangeUnrecognizedError` that those
faces — and driver-memory, and the REST selection door — all call, and
it predates objectstack-ai#17593.

It is the only non-test call site of `analyticsDateRangeRefusalMessage`
in the monorepo (grep over `packages/**`: the other five files name only
the constructor, whose signature is unchanged). Making `origin` required
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:

```
revert packages/core/src/utils/analytics-date-range.ts to 1bdbf82
  on-disk proof: two-arg call sites 1 -> 0; blob 50509de -> 4f6d482
  pnpm --filter @objectstack/core exec tsc --noEmit  :: exit 1
    src/utils/analytics-date-range.ts(222,25): error TS2554: Expected 2 arguments, but got 1.
restore (git checkout HEAD -- path; blob back to 50509de, git diff HEAD empty)
  pnpm --filter @objectstack/core exec tsc --noEmit  :: exit 0
```

The narrowing cannot land without it: `TypeScript Type Check` is one of
the six required contexts.

### B. `packages/spec/scripts/lib/format-type.ts` — REQUIRED
CONSEQUENCE, kept

The reference-docs renderer read only `items`. Draft-2020-12 spells
`z.tuple([...])` as `prefixItems` and leaves `items` ABSENT, so the
array branch rendered the element type of nothing and printed `any[]`.

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`):

```
origin/main arm  z.array(z.string())        + base renderer  ->  string[]
NARROWED arm     z.tuple([string, string])  + base renderer  ->  any[]      ** THE REGRESSION **
NARROWED arm     z.tuple([string, string])  + this renderer  ->  [string, string]
```

So landing item 1 WITHOUT this fix does not fail the docs build — it
silently rewrites `content/docs/references/data/analytics.mdx` and
`api/analytics.mdx` from `string[]` to `any[]`, 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:docs` exits 1 and names all four pages, and
`scripts/format-type.test.ts` goes 1 failed / 89 passed.

Collateral, and unavoidable: the branch is one branch in one renderer
and cannot be scoped to `dateRange` alone, so two pre-existing tuples
stop reading `any[]` in the same regeneration —
`FilterCondition.$between` (`[number | string, number | string]`) and
`ListView.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 round

Not in the inherited diff. Narrowing the authored type breaks three
sites in this suite, measured:

```
HEAD (narrowed)                pnpm --filter @objectstack/service-analytics exec tsc --noEmit
                               39 errors total, 3 in objectql-daterange.test.ts (220, 499, 503)
arm reverted + spec rebuilt    36 errors total, 0 in objectql-daterange.test.ts
HEAD + this commit             36 errors total, 0 in objectql-daterange.test.ts
```

(The 36 that remain are 24 TS2307 plus their implicit-any cascade, from
`@objectstack/core` having no `dist` in 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 door `POST /analytics/dataset/query` never opens —
deleting the case would delete objectstack-ai#17593's own coverage. Lines 499/503 are
an ordinary two-bound window whose `const` had no annotation, so
TypeScript widened the literal to `string[]`; it is annotated
`AnalyticsQuery`. Neither touches any face's behaviour. No open PR
touches `packages/services/service-analytics`.

A repo-wide sweep for the same two shapes found no other site: exactly
two `dateRange` array literals in the tree have arity other than 2 (this
one, and this card's own `@ts-expect-error` case), 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:326` and
`analyticsDateRangeRefusalMessage` at `:284`. On `origin/main`
`1bdbf82cb5` those lines carry unrelated text; the live ones before this
diff were **`:418`** and **`:376`** (they are `:496` and `:425` after
it). Both symbols are unique by name, so the ruling's subject is
unambiguous — only the citations moved.

**Why item 2 lands here: `objectstack-ai#17918` does not exist.** The 2026-09-13
re-affirmation routes item 2 to a split card, `objectstack-ai#17918`, and permits "one
PR or objectstack-ai#17918 first". `GET
/repos/objectstack-ai/issues/17918` returns **HTTP 404** on
a credential that answers 200 for objectstack-ai#17593, objectstack-ai#17596, objectstack-ai#17124 and objectstack-ai#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-②: no` holds against the mechanical check.**
`scripts/pm/check-widening-tells.mjs --declaration no --diff` over 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:

```
pnpm --filter @objectstack/spec exec vitest run --project local
  src/data/analytics-date-range-two-bound-window.test.ts
  src/data/analytics-date-range-closed-vocabulary.test.ts
  scripts/format-type.test.ts                      :: exit 0 — 3 files, 113 tests passed
pnpm --filter @objectstack/core exec vitest run --project local
  src/utils/analytics-date-range.test.ts           :: exit 0 — 45 tests passed
pnpm --filter @objectstack/spec typecheck          :: exit 0
pnpm --filter @objectstack/core exec tsc --noEmit  :: exit 0
pnpm --filter @objectstack/spec check:generated    :: exit 0 — all 15 generated artifacts up to date
pnpm lint                                          :: exit 0 (full repo scan, at 5db9a5d)
pnpm check:nul-bytes                               :: exit 0
```

Negative control for item 1, the whole point of the new suite: with
`analytics.zod.ts` reverted to the merge base (on-disk proof: tuple-arm
occurrences 1 -> 0; blob 69e3ff5 -> 8c2cce9), the two spec suites
go **10 failed / 13 passed** — every `refuses ...` case and every origin
case. Restored from `HEAD`, `git diff HEAD` empty.

Honest reading of the sibling suite:
`analytics-date-range-closed-vocabulary.test.ts` PASSES under that
ablation. Its only edits are the now-required `'schema'` argument
(ignored by the one-parameter function) and one renamed `it` title —
"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-debt` exits
**3** = `PREREQUISITE NOT MET` (29 workspace dependencies have no built
type entry point in this worktree) and `check:skill-examples` exits 1 on
the same prerequisite (`packages/client-react/dist` holds no
declarations). Both build the closure in CI. The remaining derived
families are repo-wide scans left to CI, per
`scripts/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.ts`
overwrites 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
objectstack-ai#17593's landing for this round. Successor: whoever next touches that
file, or a follow-up that finishes the objectstack-ai#5240 convergence item 2 opened
the door for.
- The same file's header states "`AnalyticsDateRangeSchema`'s array arm
is a bare `z.array(z.string())` with no length constraint" and
"Tightening `AnalyticsDateRangeSchema` itself is `packages/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.mdx` and `ui/view.mdx` gain
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 in
`session_01KB5PFtxuy1x3dcR5gxudx6`. Head `ae337c01a9`. The LEVEL of the
changeset is
untouched — `minor` is ruled.

### Verdict on the rescue commit `1974dd56dc` — right to act, wrong on
the fact; superseded

That commit (seat-made, marked INCOMPLETE AND UNREVIEWED) rewrote the
justification to read
"the string-arm closing on this same schema (objectstack-ai#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` (objectstack-ai#16322) is still
in `.changeset/`,
unreleased, and describes its own spec entry as **"a
`PROVENANCE_WAIVERS` row only"** — the
  driver half of objectstack-ai#16041, not an accept-set narrowing at the schema.
- The accept-set narrowing on this schema is **objectstack-ai#16041**, and it shipped
`"@objectstack/spec": minor` — `packages/spec/CHANGELOG.md` 17.4.0,
commit `233222e`, 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 objectstack-ai#16041, its grade and where to read it,
and disposes of objectstack-ai#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.**
`datasetSelectionRefusal` driven
directly over the built dist:

| `dateRange` | status | code | message ends |
|:--|--:|:--|:--|
| `['2026-01-01']` | 400 | `ANALYTICS_DATE_RANGE_UNRECOGNIZED` | `…
received a 1-element array, not the two bounds [start, end]. Refused at
the schema (…)` |
| `[]` | 400 | same | `… received an empty array … Refused at the schema
(…)` |
| `['a','b','c']` | 400 | same | `… received a 3-element array … Refused
at the schema (…)` |
| `['2026-01-01','2026-01-31']` | — | — | passes (`undefined`) |

Mechanism confirmed in source: `analytics-selection-door.ts` parses the
selection's shared
members — `timeDimensions` included — against
`AnalyticsQuerySchema.pick(…)`, and
`rest-server.ts:10981` calls it before `queryDataset`. ⇒ 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:

1. `packages/spec/src/data/analytics.zod.ts` — the shipped one
(`files[]` carries
   `src/**/*.zod.ts` literally).
2. `packages/core/src/utils/analytics-date-range.ts` Reachability —
pre-existing on the base,
   carried per the ruling.
3. `packages/core/src/utils/analytics-date-range.ts` "Every refusal
raised here is raised PAST
the 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`/`.status` named.
4. the `service-analytics` `objectql-daterange.test.ts` comment.

### 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 the
schema 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']`
served
`code: ANALYTICS_DATE_RANGE_UNRECOGNIZED` with 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 not
confined to the lifted path.

**Wire measurement, after** (same probe, same tree): `Too small` / `Too
big` occurrences = 0
for `['2026-01-01']`, `[]` and `['a','b','c']`; prescription occurrences
= 1; the legal window
and the preset still pass; and the mixed body still carries
`selection.measures` beside exactly
one `selection.timeDimensions.0.dateRange` entry.

**Where the collapse lives, and why there.** In `fieldsFromZodIssues`
(`packages/types/src/validation-failure.ts`), keyed on the exported
structural recogniser
`isAnalyticsDateRangeRefusalIssue`. Both REST doors report through that
one mapper — the runtime
`/analytics/query` door already did; the dataset door now does too,
instead of reading the raw
D3 `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 objectstack-ai#8124/objectstack-ai#8318
exist to end; the
selection policy in `shared/union-branch-policy.ts` is shared with the
terminal renderer and
pinned 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 `objectstack-ai#4001`
unknown-key prescriptions, which are raised at exactly that position. A
new exported helper was
rejected too: T3 of `check-widening-tells.mjs` reads a new `api-surface`
row as a widening tell,
and this PR's claim declares `Clause-②: no`.

**What is deliberately NOT collapsed** — this narrows a restatement,
never a diagnosis:

- a branch issue naming a DEEPER position is kept. `['2026-01-01', 3]`
still reports
`selection.timeDimensions.0.dateRange.1` — WHICH bound is not a string
is a location the
  prescription does not carry.
- every other union expands exactly as before. ⛔ Union-branch expansion
is not suppressed
  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 same
structural position) still reaches the wire for every other key.

`@objectstack/types` and `@objectstack/rest` join the changeset: both
publish a changed wire.

### Verification on `ae337c01a9`

- Derived gate union (`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`,
  re-derived on the final head): **113 of 115 exit 0.**
- The other 2 are `PREREQUISITE NOT MET`, exit 3, printing "⛔ This is
NOT a pass: nothing was
measured" — `check:dual-build-cjs-loads` and `check:type-check-debt`,
both of which need the
  whole monorepo built. **NOT MEASURED**, not red; CI builds fresh.
`check:skill-examples` refused the same way at first and was cleared by
building
  `client-react` / `client`: 258 examples over 3 surfaces, exit 0.
- `pnpm lint` (repo-wide, `eslint . --no-inline-config`): exit 0 — the
whole population, so no
  narrowing to justify.
- Full suites, all exit 0: spec 13624 · runtime 3644 · rest 3201 (+1
skipped) ·
  service-analytics 2392 · core 1316 · types 671.
- `typecheck` exit 0: spec, core, types, rest, runtime;
`service-analytics tsc --noEmit` exit 0
with 0 errors (the review saw 38 there, all from unbuilt workspace deps
in its worktree).
- `check:type-source-resolution` exit 0 (135 tsc programs, 80 packages)
— the new
`@objectstack/spec/data` import in `packages/types` is a fifth subpath
on a dependency that
  package already declares.
- `dispatch-gates` prints a STALE TREE warning: its one stale input is
`scripts/pm/check-harness-current.mjs`, added on `main` after this
branch, whose own header
  says "⛔ 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:32`
carries the same "which types its selection from `AnalyticsQuery` and
NEVER Zod-parses it"
sentence about `POST /analytics/dataset/query`. It is pre-existing (not
in this diff) and the
ruling 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 objectstack-ai#18232.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…commits that decided them (objectstack-ai#20729)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the eighth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/services/service-analytics/src/**` and nothing else. By the
seat's census at the claim (`5899485578`), it is the largest package in
the lane that no in-flight work holds. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines
in 22 files, covering 14 numbers**:

- 42 census sites (every census site this package has);
- 34 sites in test comments, which the census defers.

The raw scan found no dead site the gate's grammar cannot see (see
Acceptance notes), so there is no third class this time.

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **13 distinct shas**. No number in this package has an ADR or
ruling record of its own in the repository (a grep of `docs/adr/` for
all 14 finds none, and a grep of the rest of `docs/` finds none either),
so every anchor is a commit, per ruling C's order. No number was
dropped.

Only comments changed. Every touched source file keeps its line count
(78 lines out, 78 in, over 22 files), so no line citation into these
files moves. 2 of those 78 lines hold no dead citation: they are reflow
lines, listed under Wordings below. No code token moves (see the guard
below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3),
`objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the
decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them
resolves. Over the whole diff, added minus removed is 0 or negative for
every number, and no number is new to the diff. No PR number is the
citation on an added line: the two `PR #N` spellings in scope became
their pull request's squash commit, and `objectstack-ai#16750` stays only as the
convenience link beside `ed7243d52`, on the line it already stood on.

Eight dead sites are left on purpose, all of them test strings (see the
list below).

One more file: a `patch` changeset for `@objectstack/service-analytics`,
because the rewritten docblocks and inline comments ship (see Changeset
below).

The `AnalyticsResultWithDrill` type and its four sidecar members are not
touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and
`objectstack-ai#1752` all resolve).

## Census: `service-analytics`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-analytics/`. Each run counts as a reading
only because its board frontier equals the newest issue number, read by
a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-analytics sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z |
enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after),
18,548 numbers | 1,161 | **42** | 42 | 10 | 10 |
| after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186
pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers
| 1,119 | **0** | 0 | 0 | 0 |

The before count matches the seat's census at the claim and A1 (42
sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did
not move it. The whole-repo drop is 42, exactly this diff's census
sites. The `resolves` tally is 32,991 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. The after run was taken on `967d73531`; the head
`82d2b40b2` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `service-analytics/src` (162 files). It
takes its verdicts from the before census's own board reading rather
than from a second enumeration: a number is dead when that census
reported it `allocated-but-absent`, and alive when that census judged it
on this board anywhere (its `--list` extraction) and did not report it.
The 21 numbers the census never saw, because they stand only in test
files or strings here, were read one by one on the issues endpoint: 17
answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 |
| after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 |

Its src-comment column equals the census's 42, which is the control on
the second instrument. The 3,410 live citations and the 20 cross-repo
citations are the same in both readings, and the drop of 76 citations is
exactly the rewritten sites. A third, raw reading (every `#` followed by
2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84
dead before, 3,522 and 8 after; its residue equals the gate's residue
site for site, and it sees no dead site beyond the gate.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for each pair).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one
measure's own `filter` (the third producer, lowered onto
`aggregations[].filter`) is refused on both ObjectQL doors with
`INVALID_FIELD` / 400 naming the measure, folded into the one member
view, with insertion order keeping every earlier refusal's message. The
last line of its message names `objectstack-ai#11461` as the card it settles. New to
the sweep |
| `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope
resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one
constructor, so `queryDataset`'s catch re-throws them instead of reading
their words, every message byte-unchanged; plus the source-derived
wording-collision guard. Named in its diff only (18 added lines carry
the tag). New to the sweep |
| `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593):
`explicitDateRangeWindow` is the one reading of `dateRange`'s array arm
on all four faces, and an array that is not two string bounds is refused
with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only
(its changeset file is `17124-daterange-array-arm-arity.md`). New to the
sweep |
| `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure
is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400,
keyed on the `EXPRESSION_METRIC_TYPES` partition shared with
`NativeSQLStrategy`. Its message records the two failure modes the lines
describe (`driver-sql` blaming a `function` key, the in-memory evaluator
answering `null` per bucket). Named in its diff only. New to the sweep |
| `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an
aggregate a datetime measure's field type cannot carry, scoped to
temporal source fields. The squash commit of the pull request that was
`objectstack-ai#16778`; its subject carries the number. New to the sweep |
| `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's
consumer-local `executeAggregate` config mirrors (the plugin options and
`AnalyticsServiceConfig`) narrow `aggregations[].method` to
`AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in
its diff only. New to the sweep |
| `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset
vocabulary is lowered once and the rest refused, the `[range, range]`
fallback is removed from the faces it reached, and the shared
conformance kit holds them. The squash commit of the pull request that
was `objectstack-ai#17015`. New to the sweep |
| `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is
asked at the analytics door, and its bridge to the `security` service
resolves an explicit three-way (absent admits; throwing or method-less
denies at `error`, finding F3 in its message). The squash commit of the
pull request that was `objectstack-ai#16860`. New to the sweep |
| `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members,
`getDriverForObject?` and `resolveEffectiveDatasource` among them,
adopted onto `IDataEngine`, and `getObject` typed. Its subject names it.
Stage 5's and the spec stage's anchor |
| `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle`
accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type
table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The
spec stage's anchor |
| `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an
absent security service from a broken one, so a broken one refuses the
query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on
the pulls endpoint too). New to the sweep |
| `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's
headline names `objectstack-ai#16918` as the card it answers, and its diff writes the
line (`admission-bridge-resolution.test.ts:120`) |
| `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`,
not `error.details.code`; the commit that wrote this very line. The
`runtime` stage's anchor |
| `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures
fail loud, and the same commit renames
`metadata/src/utils/schema-sync-errors.ts` to
`packages/types/src/driver-error-classification.ts`, the move the line
describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest`
and `runtime` stages |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 13), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,135 commits). Each of the
14 numbers answers 404 on the issues endpoint, read one by one;
`objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls
endpoint too.

## Wordings to check

- **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7;
`[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside
the new sha.
- **The boolean rows, `measure-result-type.ts:115-116` and
`aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A,
landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became
「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d
(objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card;
`ed7243d52`'s message records the decision itself. Line 116 of the first
file and line 66 of the second are the 2 reflow lines: each keeps the
`objectstack-ai#16750` it already carried.
- **PR numbers, `read-scope-resolution-envelope.test.ts:25` and
`refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became
「Commit 5d12b16's refusal」, the pull request's squash commit.
- **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became
「commit 54b3d1d was made to remove it」, the form stage 6 used.
- **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130
forbids」 became 「the exact move commit 54b3d1d ruled out」; its message
says the fix is the declaration, not a luckier string.
- **`read-scope-resolution-envelope.test.ts:161`.** The verb after the
number moved from present to past tense with the sha.
- **`measure-expression-both-strategies.test.ts:45` and `:166`.**
「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a
added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal
(commit 017130a)」.
- **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit
0da638c's kit」, the conformance kit that commit built.
- **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit
017130a:」, whose message records the two ways the engine failed.
- **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit
6a180e4 moved it there」; that commit's diff is the rename.

## The 8 sites left

- **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all
`describe` / `it` titles:
  - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`);
  - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`);
  - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`);
- `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and
`refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`).
- There is no operator string, generated file or quoted ruling carrying
a dead number in this package. It has no generated file at all.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes never visited, base `cbaf04c1f` against head.
Template literals are therefore read in context. It ran over all 22
touched `.ts` files.

- Real run: 26,705 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns
is in」): 0 files changed, as expected (exit 0).
- Positive control, a code token added in `plugin.ts` (`field: a.field,`
given `as string`): DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/service-analytics`
(`.changeset/20596-service-analytics-provenance-anchors.md`) is
included. Its body is stage 7's, word for word, with the package name
changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build (a cache miss for this package, so
`dist` is this head's source), the rewritten comments reach `dist`:
`399ecad58` 6 times in each of `dist/index.js`, `index.cjs`,
`index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and
once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6`
once in each JS file and twice in each declaration file; `017130a09`
once in each JS file. Positive controls: the unchanged line 「none of the
coverage: a compiled measure's own」, in the same docblock as the shipped
rewrite at `objectql-strategy.ts:744`, is found once in each of the four
files, and the unchanged line 「back into line. Widening it here again
would not be a local matter」 beside the shipped rewrite at
`analytics-service.ts:559` once in each declaration file. A
never-written negative phrase appears nowhere in `dist`. None of the 14
dead numbers is left anywhere in `dist`.

## Gates (head `82d2b40b2`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 11 citations across 10 files; 10 resolve and
1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that
already stood on its line).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands:
all 56 derived at dispatch, plus `check:engine-double-contract`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. Each ran with its exit code captured before any
pipe, and all 62 exit 0. `--ran`, fed each command with its exit code,
reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A
full `turbo run build` of `./packages/*` and `./packages/*/*` ran first
under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/service-analytics test`: 137 files pass
and 3,216 tests pass. That is every test file in the package, the 12
touched ones included.
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc
--noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162
files under `src/`, the 137 test files and all 22 touched files
included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0
warnings. All 22 are in eslint's own population (`isPathIgnored` is
false for each; a `dist` file, as the control, is ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 23 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package:
- `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#`
is excluded too, which is the claim's 7. The eighth is
「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The
numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve.
- `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All
resolve; `objectstack-ai#2149`, which the census never judged, was read on its own.
  - `option #N`: none.
So nothing here needed a rewrite beyond the gate, and the raw scan
agrees.
- **「This card」 phrases are left.** 113 lines in 39 files of this
package speak of 「this card」, 「that card」 or 「the card」. They carry no
number, neither instrument sees them, and most sit in blocks whose
numbers still resolve. Stage 7 rewrote two such lines as lost referents;
here none is changed, because the phrase runs through the whole package
and rewriting a subset would be arbitrary.
- **Prose that names `queryDataset`'s catch, not changed.** Nine comment
lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in
the private `answerDataset`, whose docblock calls it the body of
`queryDataset`, so the lines still hold at the level of the public
method. This is not a dead citation, so it is outside this stage.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` →
`399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` →
`017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` →
`0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` →
`5d12b16e7`.
- **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since
moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`).
They touch `packages/spec`, `packages/metadata/package.json`,
`pnpm-lock.yaml`, docs and changesets, and no file under
`service-analytics` or in this diff, so no merge was taken; the merge
queue rebuilds on the merged generation. One of them, `671d4c164`,
declares the four drill-through sidecars on `AnalyticsResult` in the
spec. This diff leaves the local `AnalyticsResultWithDrill` untouched,
as the claim requires.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

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