diff --git a/.changeset/25330-null-label-carry.md b/.changeset/25330-null-label-carry.md
new file mode 100644
index 00000000000..3b9d8cf24a8
--- /dev/null
+++ b/.changeset/25330-null-label-carry.md
@@ -0,0 +1,16 @@
+---
+'@objectstack/spec': patch
+'@objectstack/service-analytics': patch
+'@objectstack/platform-objects': patch
+---
+
+feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System"
+
+Clause-②: no
+Metadata-protocol: none
+
+A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor:
+
+- **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes.
+- **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before.
+- **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System".
diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx
index 0b3cf2f6dc1..264264ad68f 100644
--- a/content/docs/api/data-api.mdx
+++ b/content/docs/api/data-api.mdx
@@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
-`percentScale`, `builtinAggregate` and `aggregate`: the optional members
+`percentScale`, `builtinAggregate`, `aggregate` and `nullLabel`: the optional members
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
it at compile time. Optional means a given column may omit them — read them defensively,
@@ -505,6 +505,12 @@ chart can tell a count from a sum. `builtinAggregate` is narrower: it is present
when the measure has no `label`, to mark a header that is the server's default. Neither
is set on a dimension column or a derived measure, and the cube query on this page
carries neither.
+
+A dimension column of a dataset answer carries `nullLabel` when its dataset dimension
+declares one: the name of the bucket of rows whose value for that dimension is null,
+resolved to the request's locale like `label`. A chart draws its null bucket under it
+instead of its own default. It is never set on a measure column, nor on the cube query
+on this page.
### `GET /analytics/meta`
diff --git a/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts b/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts
new file mode 100644
index 00000000000..c207d3d336c
--- /dev/null
+++ b/packages/platform-objects/src/apps/dashboards/system-overview-null-label.test.ts
@@ -0,0 +1,48 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// System Overview "Events by User": the rows the platform writes itself carry
+// no `user_id`, and they were the board's largest bar, drawn under the
+// renderer's default "(None)". The dataset dimension names that bucket with
+// `nullLabel`, which the analytics service carries to the chart on the column
+// descriptor (`fields[].nullLabel`).
+//
+// The label is an inline locale map because the translation bundles have no
+// slot for a dimension's null label, so the map must name every locale the
+// board is translated into; a missing one would show that viewer the English.
+
+import { describe, it, expect } from 'vitest';
+import { DatasetSchema } from '@objectstack/spec/ui';
+import { SysAuditLogDataset } from './system.datasets.js';
+import { SystemOverviewDashboard } from './system_overview.dashboard.js';
+import { SetupAppTranslations } from '../translations/index.js';
+
+const userDimension = () => SysAuditLogDataset.dimensions?.find((d) => d.name === 'user_id');
+
+describe('System Overview: the null-user bucket reads "System"', () => {
+ it('"Events by User" groups the audit dataset by user_id', () => {
+ const widget = SystemOverviewDashboard.widgets?.find((w) => w.id === 'widget_events_by_user');
+ expect(widget).toMatchObject({ dataset: SysAuditLogDataset.name, dimensions: ['user_id'] });
+ });
+
+ it('declares the user_id null label as an inline locale map', () => {
+ expect(userDimension()?.nullLabel).toEqual({
+ en: 'System',
+ 'zh-CN': '系统',
+ 'ja-JP': 'システム',
+ 'es-ES': 'Sistema',
+ });
+ });
+
+ it('names the bucket in every locale the platform bundles translate the board into', () => {
+ const map = userDimension()?.nullLabel as Record;
+ for (const locale of Object.keys(SetupAppTranslations)) {
+ expect(SetupAppTranslations[locale]?.dashboards?.system_overview, locale).toBeDefined();
+ expect(typeof map[locale], locale).toBe('string');
+ }
+ });
+
+ it('survives a strict parse of the dataset', () => {
+ const parsed = DatasetSchema.parse(SysAuditLogDataset);
+ expect(parsed.dimensions?.find((d) => d.name === 'user_id')?.nullLabel).toEqual(userDimension()?.nullLabel);
+ });
+});
diff --git a/packages/platform-objects/src/apps/dashboards/system.datasets.ts b/packages/platform-objects/src/apps/dashboards/system.datasets.ts
index a5b464a348d..eef0bf4ef63 100644
--- a/packages/platform-objects/src/apps/dashboards/system.datasets.ts
+++ b/packages/platform-objects/src/apps/dashboards/system.datasets.ts
@@ -49,7 +49,16 @@ export const SysAuditLogDataset = defineDataset({
object: 'sys_audit_log',
dimensions: [
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
- { name: 'user_id', label: 'User', field: 'user_id', type: 'lookup' },
+ // Rows the platform writes itself carry no `user_id`; their bucket reads
+ // "System", not the renderer's default "(None)". An inline locale map:
+ // the translation bundles have no slot for a dimension's null label.
+ {
+ name: 'user_id',
+ label: 'User',
+ field: 'user_id',
+ type: 'lookup',
+ nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' },
+ },
],
measures: [{ name: 'event_count', label: 'Events', aggregate: 'count' }],
});
diff --git a/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts b/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts
new file mode 100644
index 00000000000..947176ad9e8
--- /dev/null
+++ b/packages/services/service-analytics/src/__tests__/dataset-dimension-null-label.test.ts
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `DatasetDimension.nullLabel` reaches the renderer on the dimension column's
+ * descriptor, as `fields[].nullLabel`.
+ *
+ * The chart renderer (objectui `DatasetWidget`) reads a dataset answer's
+ * `fields[]` for every column's display metadata and never fetches the dataset
+ * document, so the null bucket's authored name has to ride that descriptor. It
+ * is resolved to a string at the request locale by the same `resolveI18nLabel`
+ * the column's `label` uses, so a viewer reads the bucket in their own
+ * language and the wire never carries a raw locale map.
+ *
+ * The other half is the control: a dimension that declares no `nullLabel`, and
+ * every measure column, carry no `nullLabel` key at all, so an answer over a
+ * dataset that never declares one is byte-identical to before.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { DatasetSchema } from '@objectstack/spec/ui';
+import type { ExecutionContext } from '@objectstack/spec/kernel';
+import { AnalyticsService } from '../analytics-service.js';
+
+const dataset = DatasetSchema.parse({
+ name: 'audit_metrics',
+ label: 'Audit Metrics',
+ object: 'sys_audit_log',
+ include: [],
+ dimensions: [
+ { name: 'action', label: 'Action', field: 'action', type: 'string' },
+ {
+ name: 'user_id',
+ label: 'User',
+ field: 'user_id',
+ type: 'string',
+ nullLabel: { en: 'System', 'zh-CN': '系统' },
+ },
+ { name: 'source', field: 'source', type: 'string', nullLabel: 'Unknown' },
+ ],
+ // `user_id` is also a measure's field: the measure column must not inherit
+ // the dimension's null label through the descriptor pass's field match.
+ measures: [
+ { name: 'event_count', label: 'Events', aggregate: 'count' },
+ { name: 'user_id_count', aggregate: 'count_distinct', field: 'user_id' },
+ ],
+});
+
+const DIMENSIONS = ['action', 'user_id', 'source'];
+const MEASURES = ['event_count', 'user_id_count'];
+
+function sqlService() {
+ return new AnalyticsService({
+ queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
+ executeRawSql: async () => [
+ { action: 'create', user_id: null, source: null, event_count: 740, user_id_count: 0 },
+ { action: 'login', user_id: 'usr_1', source: 'web', event_count: 3, user_id_count: 1 },
+ ],
+ });
+}
+
+function aggregateService() {
+ return new AnalyticsService({
+ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
+ executeAggregate: async (_object: string, options: Record) => {
+ const groupBy = (options.groupBy ?? []) as Array;
+ const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>;
+ const row: Record = {};
+ for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = null;
+ for (const a of aggregations) row[a.alias] = 1;
+ return [row];
+ },
+ });
+}
+
+const STRATEGIES: [string, () => AnalyticsService][] = [
+ ['NativeSQLStrategy', sqlService],
+ ['ObjectQLStrategy', aggregateService],
+];
+
+async function nullLabels(svc: AnalyticsService, context?: ExecutionContext) {
+ const result = await svc.queryDataset(dataset, { dimensions: DIMENSIONS, measures: MEASURES }, context);
+ const out: Record = {};
+ for (const f of result.fields) {
+ if (f.nullLabel !== undefined) expect(typeof f.nullLabel).toBe('string');
+ out[f.name] = 'nullLabel' in f ? f.nullLabel : '(no nullLabel key)';
+ }
+ return out;
+}
+
+describe.each(STRATEGIES)('dataset dimension nullLabel on the wire (%s)', (_name, service) => {
+ it('carries the declared null label, resolved to the request locale', async () => {
+ const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
+ const en = await nullLabels(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext);
+ expect(zh.user_id).toBe('系统');
+ expect(en.user_id).toBe('System');
+ });
+
+ it('carries a plain-string null label as authored, whatever the locale', async () => {
+ const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
+ expect(zh.source).toBe('Unknown');
+ });
+
+ it('falls back to the platform source language when the request states no locale', async () => {
+ const noContext = await nullLabels(service());
+ expect(noContext.user_id).toBe('System');
+ });
+
+ it('writes no nullLabel key on a dimension that declares none, nor on any measure column', async () => {
+ const cols = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
+ expect(cols.action).toBe('(no nullLabel key)');
+ expect(cols.event_count).toBe('(no nullLabel key)');
+ expect(cols.user_id_count).toBe('(no nullLabel key)');
+ });
+});
diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts
index 3702287fce4..a1493498a56 100644
--- a/packages/services/service-analytics/src/analytics-service.ts
+++ b/packages/services/service-analytics/src/analytics-service.ts
@@ -3157,16 +3157,27 @@ export class AnalyticsService implements IAnalyticsService {
if (result.fields?.length && describableDims.length) {
const dimByName = new Map(describableDims.map((d) => [d.name, d]));
const dimByField = new Map(describableDims.filter((d) => !!d.field).map((d) => [d.field as string, d]));
+ const measureNames = new Set((dataset.measures ?? []).map((m) => m.name));
for (const f of result.fields) {
- if (f.label != null) continue;
// Result fields may be keyed by the dataset dimension NAME or the
// underlying cube FIELD depending on strategy — match either.
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
if (!d) continue;
// [#6761] Same resolver, same locale, same "write nothing on a miss"
// rule as the measure enrichment above.
- const label = resolveI18nLabel(d.label, requestLocale);
- if (label !== undefined) f.label = label;
+ if (f.label == null) {
+ const label = resolveI18nLabel(d.label, requestLocale);
+ if (label !== undefined) f.label = label;
+ }
+ // The null bucket's display name (`DatasetDimension.nullLabel`), read
+ // whether or not the header was already set, and never onto a measure
+ // column whose name happens to match a dimension's field. Written only
+ // when declared and resolvable, so an answer over a dimension without
+ // one is byte-identical to before.
+ if (f.nullLabel == null && !measureNames.has(f.name.replace(/__compare$/, ''))) {
+ const nullLabel = resolveI18nLabel(d.nullLabel, requestLocale);
+ if (nullLabel !== undefined) f.nullLabel = nullLabel;
+ }
}
}
}
diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts
index 7b1e18a2c43..7fd492fe06e 100644
--- a/packages/spec/src/api/analytics.test.ts
+++ b/packages/spec/src/api/analytics.test.ts
@@ -205,6 +205,23 @@ describe('AnalyticsResultResponseSchema', () => {
expect(resp.data.fields[2].format).toBe('0.0%');
});
+ // A dimension column's null-bucket name rides the same channel. The parse
+ // strips undeclared keys, so an undeclared `nullLabel` would vanish here.
+ it('should preserve fields[].nullLabel on a dimension column', () => {
+ const resp = AnalyticsResultResponseSchema.parse({
+ success: true,
+ data: {
+ rows: [{ user_id: null, event_count: 740 }],
+ fields: [
+ { name: 'user_id', type: 'string', label: 'User', nullLabel: 'System' },
+ { name: 'event_count', type: 'number', label: 'Events' },
+ ],
+ },
+ });
+ expect(resp.data.fields[0].nullLabel).toBe('System');
+ expect('nullLabel' in resp.data.fields[1]).toBe(false);
+ });
+
// #14492 — the built-in aggregate discriminator rides the same channel, and
// it is the CLOSED `AggregationFunction` vocabulary: a spelling outside it is
// refused at the member, not stripped or passed through.
diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts
index e152cbf583c..956f40db77a 100644
--- a/packages/spec/src/api/analytics.zod.ts
+++ b/packages/spec/src/api/analytics.zod.ts
@@ -140,6 +140,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
+ 'single aggregate, and on a cube query answer (`POST /analytics/query`). Unlike '
+ '`builtinAggregate`, it says nothing about who named the column.',
),
+ nullLabel: z.string().optional().describe(
+ 'Display name of a dimension column\'s null bucket: the dataset dimension\'s `nullLabel`, '
+ + 'resolved at the request locale like `label`. A renderer draws the column\'s null values '
+ + 'under it instead of its own default. Set only on a dimension column of a dataset answer '
+ + '(`POST /analytics/dataset/query`) whose dimension declares one; absent on measure columns '
+ + 'and on a cube query answer. Display copy only: grouping is unchanged.',
+ ),
})).describe('Column metadata'),
sql: z.string().optional().describe('Executed SQL (if debug enabled)'),
totals: z.array(z.object({
diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts
index 0d0d89ac5ae..a86b2f7df33 100644
--- a/packages/spec/src/contracts/analytics-service.ts
+++ b/packages/spec/src/contracts/analytics-service.ts
@@ -136,6 +136,19 @@ export interface AnalyticsResult {
* `AggregationFunction`, the one closed aggregate vocabulary.
*/
aggregate?: AggregationFunction;
+ /**
+ * The display name of a DIMENSION column's null bucket: the dataset
+ * dimension's `nullLabel` (`DatasetDimensionSchema`), resolved to a
+ * string at the request locale by the same `I18nLabel` resolver as
+ * `label`. A renderer that buckets the column's null values draws
+ * them under this name instead of its own default (`'(None)'`).
+ *
+ * Present only on a dimension column of a `queryDataset` answer whose
+ * dataset dimension declares a `nullLabel` that resolves; absent
+ * otherwise, on every measure column, and on a `query` (cube) answer.
+ * Display copy only: it never changes which rows fall in the bucket.
+ */
+ nullLabel?: string;
}>;
/** Generated SQL (if available) */
sql?: string;