Skip to content

Commit 7c68b10

Browse files
feat(spec): a dataset dimension may name its null bucket with nullLabel (#25397)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao65RrUYonFsC9SXEirycswQ)_ Fixes #25391 Clause-②: yes (widening) Metadata-protocol: added The spec half of ruling `6358843349` on #25330 (letter A, maintainer 「同意」), verbatim: "new spec key `DatasetDimension.nullLabel` (I18nLabel, optional; final spelling settled at spec review), the objectui consumer reads it over `chart.nullCategory`, and `system.datasets.ts` sets `user_id`'s null label to 「System」." The consumer half and the `system.datasets.ts` producer follow after this lands; #25330 remains open for them. ## What changes - **`DatasetDimensionSchema.nullLabel`** (`packages/spec/src/ui/dataset.zod.ts`): optional, typed `I18nLabelSchema` (the `label` slot's own type, so a plain string or an inline locale map), `.meta({ title: 'Null Label' })`. The describe says it names the bucket of rows whose value for the dimension is null, that it is display copy only (grouping is unchanged), and that consumers fall back to their own default when it is absent. No alias and no guidance entry: no sibling contract spells this slot, so there is nothing to anchor one to. - **Liveness row** `dataset.dimensions.nullLabel`: `planned`, `evidenceScope: cross-repo`, `authorWarn: true` with an `authorHint`. Measured: objectstack's dataset compiler copies `name` / `label` / `field` / `type` / `dateGranularity` into the Cube dimension and nothing else; objectui at the `.objectui-sha` pin `0d12e953` and at main `a14737b` has no reader (its only `nullLabel` spelling is the local parameter of `chart-series.ts` `pivotSeriesBuckets`, fed from `ChartSeriesOptions.nullCategoryLabel`). - **Form reconciliation ledger** (`metadata-form-zod-reconciliation.test.ts`): one `omit` row for `dataset` / `dimensions` / `nullLabel`, in the planned-not-rendered group (`page.print`, `page.record`, `field.conditionalFormatting`). Offering a repeater column now would let an author set a label nothing shows. It also keeps `packages/platform-objects` out of this diff: a new repeater row child would owe four locale catalog entries there. - **Generated:** `content/docs/references/ui/dataset.mdx`, `packages/spec/authorable-surface/ui.json` (`ui/DatasetDimension:nullLabel`), `packages/spec/liveness/state-counts/dataset.md` (planned 0 to 1), via `check:generated --fix`. - **Changeset** `.changeset/25391-dataset-dimension-null-label.md`: `@objectstack/spec` `minor`, with both declaration lines. ## Verification (head `b22602309`) - New `packages/spec/src/ui/dataset-dimension-null-label.test.ts`, 6 cases: the string form and the locale-map form parse and keep their value; an absent key parses to the same object with no `nullLabel` materialized; `nullLabel: 42` is refused as `invalid_union` at `['nullLabel']`; an undeclared sibling key is still refused (`unrecognized_keys`); a whole `DatasetSchema` carrying it parses clean. - `pnpm --filter @objectstack/spec test`: 711/711 files, 21633 passed, 1 todo. The first run (before the ledger row) failed exactly one case, `dataset.dimensions: accepted by the Zod but unauthorable in the form`, which the ledger row answers. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `pnpm --filter @objectstack/lint test`: 155/155 files, 7178 tests. - Author warning measured end to end: `lintLivenessPropertiesFromLedgerDir` over a stack whose dataset dimension sets `nullLabel: 'System'` returns one `liveness-planned-property` finding carrying the row's hint; the same stack without the key returns none. - `node scripts/pm/dispatch-gates.mjs --commands` derived 112 families for this diff; all 112 ran and exit 0. `--ran` reconciliation: 112 run, 0 NOT-MEASURED, 0 UNRUN. On the first sweep 11 families answered exit 3 (a stale `packages/spec/dist` after the test edit, and one pinned control commit missing from the shallow clone) and `check:generated` answered 1 for the same stale dist; after `pnpm --filter @objectstack/spec build` and a fetch of that one commit, all 12 re-ran and exit 0. ## Acceptance notes - **How the key reaches the renderer is the consumer half's decision.** A dataset response describes a dimension column as `{ name, type, label }`, and the compiler does not copy `nullLabel` into the Cube. The objectui half can read it from the served dataset document, or the response's dimension descriptor can carry it, which would be a `service-analytics` change. The ruling does not pick one; noted here, not filed. - **Translation.** `translation.datasets.NAME.dimensions.DIM` declares only `label`, so a translated null label is written today as an inline locale map on the key itself. A bundle face for it would come with the consumer, if wanted. - Final spelling stays open to the spec review, per the ruling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Fd2ukZvGZZbccf6CSDPzuv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 431327b commit 7c68b10

8 files changed

Lines changed: 112 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a dataset dimension may name its null bucket with `nullLabel`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`DatasetDimensionSchema` gains an optional `nullLabel` (`I18nLabel`: a plain string or an inline locale map such as `{ en: 'System', 'zh-CN': '系统' }`). It names the bucket of rows whose value for that dimension is null, for example `'System'` on a `user_id` axis where the rows no user wrote carry no user. It is display copy only and changes no grouping.
11+
12+
- **What is accepted now.** `dimensions[].nullLabel` on a dataset, in either label form. Every dimension that parsed before parses unchanged, and the dimension still refuses any other undeclared key.
13+
- **What reads it.** Nothing yet. The chart renderer that draws the bucket under this label ships separately, and until it does the bucket keeps the renderer's own default label. The liveness ledger records the key as `planned`, so `os lint` and `os validate` warn when you set it.
14+
- **What to do.** Nothing. No existing key, value or default changes.

‎content/docs/references/ui/dataset.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const result = DatasetSchema.parse(data);
7575
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
7676
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
7777
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
78+
| **nullLabel** | `string \| Record<string, string>` | optional | Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket. |
7879

7980
### Nested Shape: `Dataset.measures[number]`
8081

@@ -111,6 +112,7 @@ const result = DatasetSchema.parse(data);
111112
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
112113
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
113114
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
115+
| **nullLabel** | `string \| Record<string, string>` | optional | Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket. |
114116

115117

116118
---

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@
411411
"ui/DatasetDimension:field",
412412
"ui/DatasetDimension:label",
413413
"ui/DatasetDimension:name",
414+
"ui/DatasetDimension:nullLabel",
414415
"ui/DatasetDimension:type",
415416
"ui/DatasetMeasure:aggregate",
416417
"ui/DatasetMeasure:currency",

‎packages/spec/liveness/dataset.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@
6868
"proof": "packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts#analytics-tz-bucketing",
6969
"note": "ADR-0054 high-risk class (analytics): a time dimension's single granularity auto-buckets at query time (dataset-executor.ts:272-287); the proof asserts the day bucket SHIFTS with the org timezone (2024-03-01T03:00Z buckets to 2024-02-29 in America/Los_Angeles) — guarding the analytics-strategy ↔ in-memory count ↔ REST exec-context integration that #2018 fixed. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:146` had rotted onto a docblock opener `/**`, and the note's `dataset-executor.ts:272-287` onto a closing brace and a comment line about the PRESENTATION's choice; the precedence resolver is at ~:315. On a row that carries an ADR-0054 high-risk proof, both pointers were wrong. Re-closed by hand against c459da6bc; DATED for the first time (this file carried no `verifiedAt` anywhere, which the review accepting that re-anchoring sorts as OLDEST).",
7070
"verifiedAt": "2026-08-28"
71+
},
72+
"nullLabel": {
73+
"status": "planned",
74+
"verifiedAt": "2026-10-23",
75+
"evidenceScope": "cross-repo",
76+
"authorWarn": true,
77+
"authorHint": "Keep it, but nothing reads it yet: no chart renderer draws a dimension's null bucket under its `nullLabel`, so the bucket still shows the renderer's own default label until the renderer half ships.",
78+
"note": "PLANNED RECORD (#25391, 2026-10-23): declared spec-first by ruling 6358843349 on #25330 (letter A), whose consumer half follows under #25330 — the objectui chart renderer reads `nullLabel` over its global `chart.nullCategory` default, and `packages/platform-objects` `system.datasets.ts` sets `user_id`'s null label. Display copy in the `label` slot's own type (I18nLabelSchema); it changes no grouping. No reader on either side today: objectstack — the dataset compiler (packages/services/service-analytics/src/dataset-compiler.ts#compileDataset) copies `name` / `label` / `field` / `type` / `dateGranularity` into the Cube dimension and nothing else, and a dataset response's dimension descriptor is `{ name, type, label }`; objectui @0d12e953 (the `.objectui-sha` pin) and main @a14737b — the only `nullLabel` spellings are the local parameter of objectui: packages/core/src/utils/chart-series.ts#pivotSeriesBuckets, fed from `ChartSeriesOptions.nullCategoryLabel ?? NULL_CATEGORY_LABEL`, never from a dataset dimension. Which channel carries the key to the renderer (the served dataset document or the response's dimension descriptor) is the consumer half's decision, not this row's."
7179
}
7280
}
7381
},

‎packages/spec/liveness/state-counts/dataset.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 |
15+
| `dataset` | 27 | 0 | 0 | 0 | 1 | 28 |

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
365365
key: 'conditionalFormatting',
366366
why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints",
367367
},
368+
// #25391 — a dataset dimension's null-bucket label lands spec-first the same
369+
// way: `planned` in the liveness ledger, read by no chart renderer until the
370+
// consumer half (ruling 6358843349 on #25330) ships. One list down, on the
371+
// `dimensions` repeater rather than the root.
372+
{
373+
kind: 'omit',
374+
type: 'dataset',
375+
path: 'dimensions',
376+
key: 'nullLabel',
377+
why: "planned, not yet rendered — the label of a dimension's null bucket is validated at parse (an I18nLabel), but no chart renderer draws the bucket under it until objectui reads it over its own default, after the spec publishes. A repeater column offered today would let an author set a label nothing shows",
378+
},
368379
{
369380
kind: 'omit',
370381
type: 'view',
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `DatasetDimensionSchema.nullLabel` — the display name of a dimension's null
5+
* bucket (ruling 6358843349 on #25330, letter A; the spec half, #25391).
6+
*
7+
* The key is optional display copy in the `label` slot's own type
8+
* (`I18nLabelSchema`): the plain string and the inline locale map both parse,
9+
* an absent key parses exactly as before it existed, and the dimension stays a
10+
* closed shape — the new key widens the accept set by one name and nothing else.
11+
*/
12+
13+
import { describe, it, expect } from 'vitest';
14+
import { DatasetDimensionSchema, DatasetSchema } from './dataset.zod';
15+
16+
const dimension = { name: 'user', field: 'user_id', type: 'lookup' as const };
17+
18+
describe('DatasetDimensionSchema.nullLabel', () => {
19+
it('accepts the plain-string form and keeps the value', () => {
20+
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 'System' });
21+
expect(parsed.success).toBe(true);
22+
expect(parsed.data?.nullLabel).toBe('System');
23+
});
24+
25+
it('accepts the inline locale-map form and keeps the value', () => {
26+
const nullLabel = { en: 'System', 'zh-CN': '系统' };
27+
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel });
28+
expect(parsed.success).toBe(true);
29+
expect(parsed.data?.nullLabel).toEqual(nullLabel);
30+
});
31+
32+
it('an absent nullLabel parses exactly as before: no key is materialized', () => {
33+
const parsed = DatasetDimensionSchema.safeParse(dimension);
34+
expect(parsed.success).toBe(true);
35+
expect(parsed.data).toEqual(dimension);
36+
expect(Object.prototype.hasOwnProperty.call(parsed.data, 'nullLabel')).toBe(false);
37+
});
38+
39+
it('refuses a value that is not a label, at the key', () => {
40+
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 42 });
41+
expect(parsed.success).toBe(false);
42+
expect(parsed.error?.issues.map((i) => [i.code, i.path])).toEqual([['invalid_union', ['nullLabel']]]);
43+
});
44+
45+
it('the dimension still refuses an undeclared key beside it', () => {
46+
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 'System', emptyText: 'System' });
47+
expect(parsed.success).toBe(false);
48+
expect(parsed.error?.issues.map((i) => i.code)).toContain('unrecognized_keys');
49+
});
50+
51+
it('a whole dataset carrying it parses clean', () => {
52+
const parsed = DatasetSchema.safeParse({
53+
name: 'events_by_user',
54+
label: 'Events by User',
55+
object: 'sys_event',
56+
dimensions: [{ ...dimension, nullLabel: 'System' }],
57+
measures: [{ name: 'event_count', aggregate: 'count' }],
58+
});
59+
expect(parsed.success).toBe(true);
60+
expect(parsed.data?.dimensions[0]?.nullLabel).toBe('System');
61+
});
62+
});

‎packages/spec/src/ui/dataset.zod.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ export const DatasetDimensionSchema = lazySchema(() => strictObject({
185185
type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional().meta({ title: 'Type' }),
186186
/** Default bucketing for date dimensions (day/week/month/quarter/year). */
187187
dateGranularity: DateGranularity.optional().meta({ title: 'Date Granularity' }),
188+
/**
189+
* The display name of this dimension's null bucket — the group of rows whose
190+
* value for the dimension is null (e.g. `'System'` on a `user_id` axis, where
191+
* the rows nobody wrote carry no user). Display copy only: it names the
192+
* bucket, it never changes which rows fall in it or how they are grouped.
193+
* When absent, a consumer draws its own default for that bucket.
194+
*/
195+
nullLabel: I18nLabelSchema.optional()
196+
.describe(
197+
'Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). '
198+
+ 'Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket.',
199+
)
200+
.meta({ title: 'Null Label' }),
188201
}));
189202

190203
/**

0 commit comments

Comments
 (0)