Skip to content

fix(analytics): a min/max over a string-valued field is described as string, not number - #16238

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-16098-measure-result-type-string-family
Sep 6, 2026
Merged

fix(analytics): a min/max over a string-valued field is described as string, not number#16238
os-zhuang merged 2 commits into
mainfrom
claude/issue-16098-measure-result-type-string-family

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #16098

The sibling population of the temporal half. min and max return a value of the aggregated field's own type, so a min over a text / select / lookup / autonumber column carries a string — and every one of those columns was still described as type: "number", the same self-contradiction the temporal fix removed for date / datetime / time.

measureResultType already answered undefined for "no correction", so this is rows added to one table, not a second mechanism: the rule keeps its single home, queryDataset's ADR-0021 result-column enrichment keeps applying it once downstream of all four producers, and no call site changes.

The framing: the card's "uncertain" members were measured, not guessed

The card split the population into certain and uncertain and warned that a correction table covering the uncertain members "would be inventing answers". That is true only of a guess. Every member below was put in a bucket by reading the two shipped statements of what the type STORES — the spec's runtime value contract (packages/spec/src/data/field-value.zod.ts, ADR-0104 D1) and driver-sql's DDL column switch — plus, where those were open, the producer itself.

Two of the three members the card flagged as uncertain turned out to have a measured answer:

  • autonumber is a string. renderAutonumber returns value: prefix + String(seq).padStart(width,'0') + suffix — a string by construction, zero-padded under the contract default format {0000}; the DDL answers col = table.string(name); and RUNTIME_OWNED_FIELD_TYPES makes the runtime the producer of that string. Measured directly on SQLite in this container, min/max over a column of padded record numbers answers the padded TEXT ('0003' / '0012'), typeof string. The spec's value contract answers z.unknown() here, but that is a statement about what a CALLER may write, not about what the producer emits — reading the openness as "unknowable" is what would have made this member a guess.
  • summary is numeric. Both shipped statements agree: it is a member of the spec's NUMERIC_VALUE_TYPES (so valueSchemaFor answers z.number().finite()) and the DDL answers col = table.float(name). So the number it already carried is CORRECT, not merely unexamined — there is nothing to correct.

One did not, and is left alone with the measurement stated: boolean.

Per-FieldType verdict — all 49 members

string and time are DimensionType words; every undefined row keeps the number its producer minted. The three undefined buckets are deliberately kept apart: "already correct", "no backend-independent answer exists" and "the answer is not on this input" are different findings, and collapsing them into one default branch is exactly how the uncertain members would get silently swallowed.

member bucket wire type the measurement behind it
text string string STRING_VALUE_TYPESz.string(); TEXT column
textarea string string STRING_VALUE_TYPESz.string(); TEXT column
email string string STRING_VALUE_TYPESz.string(); TEXT column
url string string STRING_VALUE_TYPESz.string(); TEXT column
phone string string STRING_VALUE_TYPESz.string(); TEXT column
password string string stored plaintext-or-hashed, masked on read — a string either way
secret string string stores an opaque sys_secret ref, masked on read — a string either way
markdown string string a multi-line body in a TEXT column
html string string a multi-line body in a TEXT column
richtext string string a multi-line body in a TEXT column
code string string stores the editor contents verbatim (measured in field-zoo, moved to the text family)
color string string a color code string
signature string string a data-URI string; the write seam enforces its declared bound
qrcode string string a data-URI string; the write seam enforces its declared bound
select string string SINGLE_OPTION_TYPES — one option code; optionCodes stringifies a numerically-spelled code before the value schema is built
radio string string same branch as select
lookup string string REFERENCE_VALUE_TYPESReferenceIdValueSchema (z.string()) — the id, never the expanded record
master_detail string string same ReferenceIdValueSchema
tree string string same ReferenceIdValueSchema
user string string a lookup fixed to sys_user; identical storage
autonumber string string rendered zero-padded string; DDL table.string; measured on SQLite (see above)
date temporal time a calendar day, stored YYYY-MM-DD (landed earlier)
datetime temporal time an ISO instant with explicit zone (landed earlier)
time temporal time a wall-clock time of day (landed earlier)
number numeric-correct NUMERIC_VALUE_TYPES; a numeric column
currency numeric-correct a bare number on the wire (ADR-0104 header)
percent numeric-correct NUMERIC_VALUE_TYPES; a numeric column
rating numeric-correct NUMERIC_VALUE_TYPES; a numeric column
slider numeric-correct NUMERIC_VALUE_TYPES; a numeric column
progress numeric-correct NUMERIC_VALUE_TYPES; a numeric column
summary numeric-correct NUMERIC_VALUE_TYPES and DDL table.float — both statements say numeric
boolean backend-dependent three readings, and they disagree (see below)
toggle backend-dependent a boolean rendered as a switch — same column, same three readings
multiselect backend-dependent an array in a JSON column: no min over jsonb on Postgres, serialized TEXT on SQLite
checkboxes backend-dependent an array in a JSON column, same as multiselect
tags backend-dependent a free-form array in a JSON column, same as multiselect
image backend-dependent stored form mid-migration (ADR-0104 D3): contract says opaque id, DDL still a JSON column
file backend-dependent stored form mid-migration, same as image
avatar backend-dependent stored form mid-migration, same as image
video backend-dependent stored form mid-migration, same as image
audio backend-dependent stored form mid-migration, same as image
composite backend-dependent an object in a JSON column
repeater backend-dependent an array of objects in a JSON column
record backend-dependent a name-keyed map in a JSON column
location backend-dependent a {lat,lng} object in a JSON column
address backend-dependent a structured object in a JSON column
vector backend-dependent a number array in a JSON column
json backend-dependent the untyped escape hatch; the value contract is explicitly open (z.unknown())
formula not-on-this-input the answer is declared, but not on this rule's input (see below)

Twenty-one corrected, three already corrected by the temporal half, twenty-five left as they were.

What is left uncorrected, and why

boolean / toggle — measured, and the readings do not converge

So the three readings disagree about whether a value exists at all, and about what kind the one answering backend reports. DimensionType does carry a boolean word, so the correction is spellable here — which is precisely why it is not made: spelling it would ship one of three disagreeing readings as a published declaration. The column keeps the number it has, which is also the accurate word for the raw SQLite value. The missing refusal is owned by #16099 (needs-user-decision: no layer refuses an incoherent aggregate / field-type pair), and the boundary noted honestly: the driver-seam reading is cited from #11455, not re-measured here.

The JSON-column classes, and the file types

MULTI_OPTION_TYPES and STRUCTURED_JSON_TYPES are JSON_COLUMN_TYPES in driver-sql, so the aggregate runs over a JSON column: no min over jsonb on Postgres, serialized TEXT on SQLite. Same shape as the boolean case, same owner in #16099.

FILE_REFERENCE_TYPES is left alone for a second, additive reason: its stored form is mid-migration under ADR-0104 D3. The value contract's stored schema is already the opaque sys_file id (FileReferenceIdValueSchema, a string) while the DDL still gives these types a JSON column for the pre-D3 inline metadata object. Two shipped statements, two different stored forms; correcting to either would describe half the deployments.

formula — the answer exists, but not on this rule's input

FieldSchema.returnType (number / text / boolean / date) declares it, and its own JSDoc names "dataset measures" as the intended consumer. This rule cannot read it: its input is the declared FieldType alone, because that is all AnalyticsServiceConfig.sourceFieldMeta returns ({ type?, defaultCurrency?, max? }). returnType is also optional — "absent when the type can't be proven" — so even with the plumbing the rule would answer for some formula fields and not others. Carrying it changes the host callback contract and its call site; filed as #16236 rather than guessed at.

What the rule deliberately cannot see: multiple

sourceFieldMeta returns no multiple flag, so a select / radio / lookup / user declared multiple: true — stored as a JSON array — is indistinguishable here from its single-valued form and is corrected to string with the rest of its class. That is the safe direction rather than an oversight: where the backend answers at all it is SQLite comparing the serialized TEXT, which is a string; where it does not answer (Postgres over jsonb) there is no response for any word to mis-describe. Stated in the module header rather than left implicit.

Why string, and not a sixth word

fields[].type is not a FieldType position — it speaks DimensionType (string / number / boolean / time / geo). Both words this rule mints are already carried by DIMENSION columns in the very same response: dataset-compiler.dimensionType maps a date dimension to 'time' and a lookup dimension to 'string', and both buildFieldMetas copy that through. Spelling a textual measure 'text' would have added a sixth word to a five-word vocabulary and left every existing consumer branch unreached — the same argument that chose time over datetime. Section D pins both halves of it.

Membership is composed, not re-listed

STRING_SOURCE_FIELD_TYPES is built from @objectstack/spec's own value classes — STRING_VALUE_TYPES, SINGLE_OPTION_TYPES, REFERENCE_VALUE_TYPES — plus autonumber as the one local extra, the same construction driver-sql uses for its JSON_COLUMN_TYPES. What the platform says a field type STORES and what this rule says a min over it RETURNS therefore cannot drift.

The enum-walking pin, and its population

src/__tests__/measure-result-type.test.ts section A walks both closed vocabularies:

  • AggregationFunction — 6 members, each with a row saying whether this rule speaks about it. The pre-existing guard, kept.
  • FieldType49 members, each with a row carrying its bucket and the measurement behind it, asserted equal to FieldType.options in both directions. A field type added to the spec fails that assertion instead of silently inheriting the flat number; a field type REMOVED fails it too.

Per member the pin asserts min and max answer the bucket's verdict, and that the other four aggregates never correct anything — so the table is walked 49 × 6 rather than sampled. A further assertion requires all five buckets to be non-empty, so the honest split cannot be quietly collapsed into one default branch. Section E drives the string family end to end through queryDataset (the primary producer, the supplementary-sub-query producer and the __compare producer), and asserts the four uncorrected representatives — boolean, json, formula, summary — still say number, each for its own recorded reason.

Total: 121 tests in this file, up from 27.

Verification

All measurements taken at d09f74de1, the head of this branch. Every exit code captured immediately after a single redirected command, never through a pipe.

what command result
this file pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 src/__tests__/measure-result-type.test.ts exit 0 — Test Files 1 passed (1), Tests 121 passed (121)
whole package pnpm --filter @objectstack/service-analytics test exit 0 — Test Files 94 passed (94), Tests 2134 passed (2134)
package types pnpm --filter @objectstack/service-analytics typecheck exit 0 (tsc --noEmit)
derived gate family 55 families, --commands 55 of 55 exit 0

Both heavy runs went through scripts/pm/os-verify-lock.sh; the results quoted are its VERDICT command-exit lines, not a bare $?.

The typecheck really covers the edited files. tsc --noEmit --listFiles names both of them — measure-result-type.ts and __tests__/measure-result-type.test.ts — so "typecheck clean" is a statement about this diff and not a true sentence that says nothing about it.

Gate family. Derived mechanically with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on this final head; its Reconciliation line reads 55 famil(ies) (44 by path + 6 by kind + 7 declared whole-tree, 2 reached both ways), and the list run is --commands, which prints exactly those 55. The Artifact-rosters block (39 families) is excluded from that total by the tool and was not counted as part of it. Included and green: check:nul-bytes, check:cross-package-test-inputs, check:engine-double-contract, check:type-check-coverage, check:type-check-debt, check:published-files, check:changeset-gate-self-tests, check:empty-changeset, check:changeset-no-major, check:closing-keyword-parity.

One family needed a second run to be measured at all: pnpm check:type-check-debt first exited 3 — its --re-measure OOM'd. That is PREREQUISITE NOT MET, which the gate's own output says is "NOT a pass and NOT a finding: nothing was measured". Cause is mine, not the gate's: the outer NODE_OPTIONS=--max-old-space-size=4096 was BELOW the ceiling the gate pins for its inner tsc, which its own output states as tsc runs under --max-old-space-size=6144 MB. Re-run at 8192 it exits 0: 12 ledger entr(ies) re-measured in 127.3s, 140 raw tsc error(s) total, none above its recorded number, surplus: none.

Lint is a declared narrowing, not a skipped step. eslint was run over the files this diff touches rather than over the repo, and the narrowing is measured on all three counts: (1) the receiving population comes from eslint's own configuration, not from my judgement — asked about the third file in the diff, the changeset .md, it answers File ignored because no matching configuration was supplied, so its population over this diff is exactly the two .ts files; (2) the count is read from --format json — 2 entries, 0 errors, 0 warnings, exit 0; (3) the invariance for untouched files is a property of the config, which eslint.config.mjs states of itself: this repo runs one config that "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not" — so no edit in this diff can move a verdict on a file it does not touch. The repo-wide run remains CI's.

Reverse verification — direction predicted before running

Predicted, and written down first: deleting only the string branch from measureResultType must turn RED every assertion expecting 'string' — the 21 string-bucket rows in section A, the section C producer control, the section D lookup-dimension control, and all four section E end-to-end cases, 27 in total — while leaving the temporal half (section B) and every number control (section D) GREEN. Ordinary direction: the change corrects a value on existing entries, mints no column and removes no limb, so nothing downstream can gain or lose a finding.

Measured: 27 failed | 94 passed (121), and the failing set is exactly the predicted one, one row per string-bucket member by name plus the four end-to-end cases and the two controls. The temporal assertions stayed green throughout, which is what shows the new rows are load-bearing on their own rather than riding on the landed half.

Ablation discipline, in full:

  • Committed before mutating — the implementation was at 202f797d8 before the mutation, so HEAD genuinely held it.
  • Mutation proven on disk, not by the editor's exit code: anchor count 1 → 0, injected marker count 1, and the blob hash moved 806b5a36… → 6be5d73b…. The run would have aborted on any of the three.
  • No rebuild needed, and the condition was checked rather than assumed: the ablated subject is reached by a RELATIVE import inside the same package (../measure-result-type.js), not across a package exports boundary, so no dist sits between the mutation and the test. The ablation itself is the proof — mutating src/ alone moved the result.
  • Restore under trap … EXIT INT TERM with an absolute path, spelled git checkout HEAD -- "$REPO_ROOT/RELPATH" so it cannot read the mutation back out of the index.
  • Restore proven, not assumed: the file's hash equals its HEAD blob (806b5a36… both sides), git diff HEAD is empty and git status --porcelain is clean.

The call site in analytics-service.ts was deliberately NOT the ablation subject: it is held by another open PR, so this branch does not touch it even transiently. The claim that ablation would otherwise have tested — that the correction is applied downstream of all four producers — is pinned instead by section C, which drives one selection down both buildFieldMeta producers and requires their column metadata to be equal.

Scope

measure-result-type.ts and its test only; the fenced files (analytics-service.ts, preview-evaluator.ts, text-match-sql.ts / like-pattern.ts) are untouched — widening the table needed no call-site change, which is what "rows in one table, not a second mechanism" buys.

Changeset: minor on @objectstack/service-analytics, matching the temporal half's grade for the same reason — a published wire surface reports a different value for existing queries, which is behaviour a consumer branches on. No spec change here, so no @objectstack/spec bump.

Filed while working this card, not addressed here: #16236 (sourceFieldMeta cannot carry formula.returnType) and #16237 (a summary roll-up of min/max over a non-numeric child field writes into a column both statements declare numeric). Both unassigned, both out of scope: #16236 needs the fenced call site, #16237 lives in packages/spec / packages/objectql.

🤖 Generated with Claude Code


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 1ebde49536b431dc891b4bbe1a144b638c6205aepackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1ebde49536b431dc891b4bbe1a144b638c6205ae

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM verification — PASS

Measured at head d09f74de1 against merge-base f5aec38a6, independently of the report.

Fence — held

Local merge-base diff (⛔ not GitHub's list): 3 files — the rule, its test, one changeset. grep over the changed paths for analytics-service|preview-evaluator|text-match-sql|like-pattern returns 0. Three sibling PRs are live in this package (#16192, #16204, #16235) and none overlaps. The fenced call site in analytics-service.ts is untouched even transiently.

The executable change is four lines, and that matters

Stripping the docblock out of the diff leaves almost nothing:

+export const MEASURE_RESULT_TYPE_STRING = 'string';
+export const STRING_SOURCE_FIELD_TYPES: ReadonlySet<string> = new Set<string>([
+  ...STRING_VALUE_TYPES, ...SINGLE_OPTION_TYPES, ...REFERENCE_VALUE_TYPES, 'autonumber',
+]);
-  return TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType) ? MEASURE_RESULT_TYPE_TEMPORAL : undefined;
+  if (TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_TEMPORAL;
+  if (STRING_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_STRING;
+  return undefined;

One if added to the existing rule, temporal arm checked first and byte-unchanged. ⭐ No second mechanism and no call-site change — which is the constraint the brief set, and the reason the correction still reaches all four producers through the one seam.

The membership is COMPOSED, not re-listed — checked at the source

This is the part that decides whether the fix rots. The set spreads three spec classes rather than copying their members, so a type moved between classes upstream moves here with it. Read at packages/spec/src/data/field-value.zod.ts:

  • STRING_VALUE_TYPES (:56) — text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode
  • SINGLE_OPTION_TYPES (:88) — select, radio
  • REFERENCE_VALUE_TYPES (:101) — lookup, master_detail, user, tree

All three match the docblock's table exactly. Each is declared satisfies readonly FieldType[], so an upstream typo cannot silently shrink the population.

A type ADDED to the enum fails loudly — verified, not taken on trust

The durability claim rests on the pin walking the vocabulary, so I read it rather than the prose. measure-result-type.test.ts:218 asserts the aggregate roster equals [...AggregationFunction.options].sort(), and :226 asserts the field-type roster equals [...FieldType.options].sort(). Both directions: a member added upstream and not given a verdict fails the test rather than silently inheriting the flat number this rule exists to correct.

autonumber — the uncertain member was MEASURED, and I re-took both readings

The brief's middle bucket was looks uncertain but is measurable ⇒ measure it, ⛔ do not skip. It was measured, and I confirmed both shipped statements myself:

  1. packages/spec/src/data/autonumber-format.ts:266value is `${prefix}${String(seq).padStart(width, '0')}${suffix}`, a template literal, so a string by construction, not by convention.
  2. packages/drivers/driver-sql/src/sql-driver.ts:15823case 'auto_number': case 'autonumber':col = table.string(name).

⭐ The reasoning I want on the record: the spec's value contract answers z.unknown() for this type, and the seat did not read that as "unknowable". It is a statement about what a caller may write; the producer is the runtime, and the producer is readable. Treating an open write-contract as an unanswerable question is exactly how a member ends up in the wrong bucket by default.

The same file's case 'summary': col = table.float(name) corroborates the other direction — summary keeps its existing number because that is correct, not merely unexamined.

What was deliberately left uncorrected — the honest bucket

boolean/toggle is the one I checked hardest, because a DimensionType word ('boolean') does exist and spelling it would have looked like completeness. Three readings disagree about whether a value even exists: Postgres has no min(boolean) at all (42883), SQLite answers 0/1 as numbers, and the driver seam recorded in #11455 answers false/true. ⛔ Correctly not spelled — a word chosen where the backends disagree would be this rule's own defect class re-created. Same for the JSON-column families, the file types (stored form mid-migration under ADR-0104 D3) and formula (its answer lives on FieldSchema.returnType, which sourceFieldMeta does not carry — filed as #16236). #16099 is named as the owner of the missing refusal in each case.

Spelling

'string' — the DimensionType word a lookup dimension column in the same response already carries. ⛔ No sixth word enters the wire vocabulary, which is the same argument the temporal half used for 'time' over 'datetime'.

Contract surface

measure-result-type.ts is not re-exported from index.ts, and the package declares exactly one export ("."). MEASURE_RESULT_TYPE_STRING and STRING_SOURCE_FIELD_TYPES are therefore module-internal and reach the published .d.ts nowhere — the basis for the Clause-②: no I filed on card #16098.

Ablation

Direction predicted before running: deleting only the string branch reds the 21 string rows, the producer control, the lookup-dimension control and the 4 end-to-end cases = 27. Measured 27 failed | 94 passed (121), failing set exactly as named. Mutation proven on disk by blob delta plus anchored counts, restore proven by blob equality and an empty git diff HEAD. No rebuild leg owed and the condition was checked rather than assumed — the pin imports ../measure-result-type.js, a relative same-package specifier, so no dist/ sits between the mutation and the test.

Verdict: PASS. needs:contract-review applied — opening a PR is not submitting it. Card #16098 already carries its readable Claim: + Clause-②: no declaration (5557675324), so ⛔ this pair should not repeat the carrier hold that stalled #16148 / #16192 / #16204 / #16216.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS · landing held on the pair declaration — PR #16238 at head d09f74de (Fixes #16098)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 07:52Z; taken here because the domain:services seat's tier fuse is blown.

Clause ② answer: no exported-surface move; conformance limb yes — the descriptor now says what the value already is. measure-result-type.ts is not re-exported from @objectstack/service-analytics's barrel (checked on origin/main), so the new MEASURE_RESULT_TYPE_STRING / STRING_SOURCE_FIELD_TYPES are module-internal and no published .d.ts moves; measureResultType's signature is unchanged. What moves is fields[].type on POST /analytics/dataset/query for a min/max measure over the twenty-one string-valued FieldType members: numberstring, the DimensionType word a lookup dimension in the same response already carries. min/max return a value of the operand's own type, so the pre-fix descriptor contradicted the value beside it — the same self-contradiction #16101 removed for the temporal family; rows added to one table, no second mechanism, no call-site change.

What was read for the verdict: membership is composed from the spec's own value classes (STRING_VALUE_TYPES, SINGLE_OPTION_TYPES, REFERENCE_VALUE_TYPES) plus autonumber, whose string-ness is measured on the producer (renderAutonumber pads text; DDL table.string; SQLite returns padded text). The five buckets are kept apart and every one of the 49 FieldType members carries a verdict, pinned by an enum-walking test that fails on an added or removed member. The uncorrected classes are correct not to correct: boolean/toggle have three disagreeing readings (Postgres 42883, SQLite numbers, driver seam booleans) — spellable, deliberately not spelled; the JSON-column classes and the mid-migration file types have no backend-independent value; formula is answerable only from returnType, which sourceFieldMeta does not carry (#16236 filed, not guessed); summary is numeric on both shipped statements. multiple: true is invisible to the rule and corrected in the safe direction, stated in the header.

Fences held: analytics-service.ts, preview-evaluator.ts, text-match-sql.ts / like-pattern.ts untouched; the ablation subject was this file, not the fenced call site, with section C pinning the downstream application instead.

Changeset: @objectstack/service-analytics: minor — accepted on the same grounds as the temporal half (#16101): a wire descriptor changes for existing queries; level moot in effect under the fixed group. CI at d09f74de: 31 success · 6 skipped · 0 failing. Governed-merge audit on the 3 paths: 0 hits.

Landing — held on one thing

--pair 16238 exits 4: card #16098's claim has no Claim: / Clause-②: spelling. needs:contract-review comes off this PR now (card never carried it). On pair exit 0 the next director pass flips ready-for-review + auto-merge (squash).


Generated by Claude Code

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.

A min/max over a text / select / lookup field is still described as type: "number" in the analytics response

3 participants