Skip to content
24 changes: 24 additions & 0 deletions .changeset/33545-duplicate-field-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

feat(lint)!: refuse an object that shows two visible fields under one label (`field-label-duplicate`)

Clause-②: no (narrowing) — `os build`, `os validate`, `os lint` and the metadata publish door (an `object` write) now refuse an object whose visible fields, the platform's injected columns included, share one label in the authored text or in any locale a reader is served. Shipped first-party metadata hit: two example apps, relabelled here (`example-showcase`: `showcase_task.assignee` and `showcase_team.lead`, both served 负责人 to a `zh-CN` reader beside `owner_id`; `example-todo`: `todo_task.owner`, likewise); every other example, every first-party app under `packages/apps` and HotCRM build clean.
Metadata-protocol: narrowed
Skills-docs: none — the authoring half (an AI builder reusing `owner_id` instead of adding a second "Owner" field) is the skills lane's on the parent card; `os explain field-label-duplicate` carries the rule's full text, and four CLI transcripts in `content/docs` show the author-time rule count moving from 67 to 68.

<!-- adr-0087: registered object-field-label-duplicate-refused -->

**BREAKING** for metadata that builds today: an object that shows two or more visible fields under one label is now an error. Every surface draws a field under its label (list grid headers, kanban card lines, form captions, export and import columns), so two fields of one label are two columns a person cannot tell apart, and editing one reads as editing the other. The common case is a field added beside the platform's own: a `user` field labelled like `owner_id`, which a `zh-CN` reader is served as "负责人".

What is compared: the fields the object serves (its own plus every `objectExtensions` field of the stack) and the columns the platform injects on it (`owner_id`, `created_at`, `created_by`, `updated_at`, `updated_by`; the organization, business-unit and queue columns are hidden). A field declared under an injected column's name replaces it. `hidden: true` fields are not compared. Labels are compared trimmed, once as authored and once per locale (every locale a `translations` bundle carries, plus `en`, `zh-CN`, `ja-JP` and `es-ES`, the locales the platform translates the injected columns into), through the same `translateObject` the `/meta` read serves them with.

FROM → TO, and the fix:

- FROM an object with `assignee: { type: 'user', label: '负责人' }` beside the injected `owner_id` building clean TO `field-label-duplicate` naming `'assignee' and 'owner_id'`, the label and `locale zh-CN`. Fix: use `owner_id` instead of a second owner field, or label the field apart (for example "处理人"), or set `hidden: true` on it.
- FROM two authored fields labelled "Status" building clean TO the same error, naming both fields and no locale. Fix: give each its own label.
- FROM a translation bundle that gives two fields one label in one locale building clean TO the error naming that locale. Fix: translate them apart in that bundle.

At the publish door a write of another object is never refused for a clash a stored object carries; saving the clashing object itself is refused until the clash is fixed, as for every gating rule at that door. No boot or registration path runs the author-time rules, so an installed app keeps loading and serving its objects as before.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (67)...
→ Running author-time rules (68)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (67)...
→ Running author-time rules (68)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (67)...
→ Running author-time rules (68)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (67)...
→ Running author-time rules (68)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
11 changes: 7 additions & 4 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,10 @@ export const ShowcaseTranslationBundle = {
title: { label: '标题' },
// The project page's Tasks list, titled by this master-detail.
project: { label: '项目', relatedListTitle: '任务' },
assignee: { label: '负责人' },
// Not 负责人: that is what a zh-CN reader is served for the platform's
// `owner_id` on this object, and two columns of one label cannot be
// told apart (`field-label-duplicate`).
assignee: { label: '处理人' },
status: {
label: '状态',
options: { backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '评审中', done: '已完成' },
Expand Down Expand Up @@ -859,7 +862,7 @@ export const ShowcaseTranslationBundle = {
},
showcase_team: {
label: '团队', pluralLabel: '团队',
fields: { name: { label: '团队名称' }, lead: { label: '负责人' }, capacity_hours: { label: '产能(小时)' } },
fields: { name: { label: '团队名称' }, lead: { label: '组长' }, capacity_hours: { label: '产能(小时)' } },
_sections: { team: { label: '团队' } },
},
showcase_project_membership: {
Expand Down Expand Up @@ -1002,7 +1005,7 @@ export const ShowcaseTranslationBundle = {
// because `check-i18n-coverage` ratchets this example at its current
// untranslated count: a newly declared label that skips zh-CN pushes the
// count up and fails the gate. (The gallery's older params sit inside
// that baseline; this one is not allowed to widen it.) `负责人` matches
// that baseline; this one is not allowed to widen it.) `处理人` matches
// what the bundle already renders for `showcase_task.assignee`, rather
// than introducing a second word for the same idea.
// 动作显隐矩阵(objectui#3492 / #3501)。同上:本示例被
Expand All @@ -1013,7 +1016,7 @@ export const ShowcaseTranslationBundle = {
showcase_action_param_gallery: {
params: {
p_assignee: {
label: '负责人',
label: '处理人',
helpText: '指向系统对象的内联查找 —— 可按姓名或邮箱搜索,无需填写 UUID。',
},
},
Expand Down
6 changes: 4 additions & 2 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export const zhCN: TranslationData = {
days_overdue: { label: '逾期天数', help: '距截止日期已过的整天数;未逾期或未设置截止日期时为 0' },
reminder_date: { label: '提醒日期/时间' },
completed_date: { label: '完成日期' },
owner: { label: '负责人' },
// Not 负责人: a zh-CN reader is served that for the platform's `owner_id`
// on this object too (`field-label-duplicate`).
owner: { label: '处理人' },
tags: {
label: '标签',
options: {
Expand Down Expand Up @@ -104,7 +106,7 @@ export const zhCN: TranslationData = {
reports: {
todo_tasks_by_status: { label: '按状态统计任务', description: '按状态分组的任务汇总' },
todo_tasks_by_priority: { label: '按优先级统计任务', description: '按优先级分组的任务汇总' },
todo_tasks_by_owner: { label: '按负责人统计任务', description: '按负责人汇总的任务' },
todo_tasks_by_owner: { label: '按处理人统计任务', description: '按处理人汇总的任务' },
todo_completed_tasks: { label: '已完成任务', description: '所有已完成任务及其工时记录' },
todo_time_tracking: { label: '工时跟踪报表', description: '预计工时与实际工时分析' },
},
Expand Down
30 changes: 30 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import { lintConnectorCredentialLiterals } from './lint-connector-credential-lit
import { lintLivenessProperties } from './lint-liveness-properties.js';
import { lintAutonumberFormats } from './lint-autonumber-formats.js';
import { lintViewRefs } from './lint-view-refs.js';
import { lintDuplicateFieldLabels } from './lint-duplicate-field-labels.js';
import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
import { validateEmailTemplateHolePlacements } from './validate-email-template-hole-placements.js';
import { validateEmailTemplateHoleGrammar } from './validate-email-template-hole-grammar.js';
Expand Down Expand Up @@ -2345,6 +2346,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
hint: f.fix ?? '',
})),
},
// Two visible fields of one object served under one label
// (`field-label-duplicate`): the platform's `owner_id` and an author's
// `assignee`, both read as "负责人" by a zh-CN reader, are two columns of one
// name in every grid, card and form, and editing one reads as editing the
// other. GATING on all three commands and on the runtime publish gate's
// `object` writes, the door an AI or Studio author has. The labels compared
// are the ones `translateObject` serves (see the rule's module note). The
// gate's differential never charges a stored object's clash to a write of
// another object; a re-save of that object is judged on its own body, as
// for every gating rule at this door. No boot or registration path runs
// this table, so an app already installed keeps loading.
{
name: 'lintDuplicateFieldLabels',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/lint-duplicate-field-labels.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['object'],
run: (stack) =>
lintDuplicateFieldLabels(stack).map((f) => ({
severity: f.severity,
rule: f.rule,
where: f.where,
path: f.path,
message: f.message,
hint: f.hint,
})),
},
// #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a
// single-column declared unique index states two scopes of which at most one
// takes effect (`unique/double-declaration`, the four-quadrant matrix).
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,9 @@ export {
AUTONUMBER_LITERAL_TOKEN,
} from './lint-autonumber-formats.js';

export { lintDuplicateFieldLabels, FIELD_LABEL_DUPLICATE } from './lint-duplicate-field-labels.js';
export type { DuplicateFieldLabelFinding } from './lint-duplicate-field-labels.js';

export { lintViewRefs } from './lint-view-refs.js';
export type { ViewRefFinding } from './lint-view-refs.js';
export {
Expand Down
101 changes: 101 additions & 0 deletions packages/lint/src/lint-duplicate-field-labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { BUILTIN_SYSTEM_FIELD_LABEL_LOCALES } from '@objectstack/spec/system';

import { AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js';
import { explainRule } from './rule-explanations.js';
import { FIELD_LABEL_DUPLICATE, lintDuplicateFieldLabels } from './lint-duplicate-field-labels.js';

/** The staging object: an AI-added `user` field labelled like the platform's `owner_id` in zh-CN. */
function ticketObject(assignee: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: 'e9gv_ticket',
label: '工单',
fields: {
title: { type: 'text', label: '标题' },
assignee: { type: 'user', label: '负责人', ...assignee },
},
};
}

describe('lintDuplicateFieldLabels', () => {
it('reports the injected owner_id and an authored field the zh-CN reader sees under one label', () => {
const findings = lintDuplicateFieldLabels({ objects: [ticketObject()] });
expect(findings).toHaveLength(1);
const [f] = findings;
expect(f.rule).toBe(FIELD_LABEL_DUPLICATE);
expect(f.severity).toBe('error');
expect(f.where).toBe('object "e9gv_ticket"');
expect(f.path).toBe('objects[0].fields');
expect(f.message).toContain("'assignee' and 'owner_id'");
expect(f.message).toContain('"负责人"');
expect(f.message).toContain('locale zh-CN');
expect(f.hint).toContain("'owner_id'");
});

it('does not compare a hidden field', () => {
expect(lintDuplicateFieldLabels({ objects: [ticketObject({ hidden: true })] })).toEqual([]);
});

it('passes fields whose labels differ in every locale', () => {
expect(lintDuplicateFieldLabels({ objects: [ticketObject({ label: '处理人' })] })).toEqual([]);
});

it('does not compare owner_id where the platform injects none', () => {
const obj = { ...ticketObject(), ownership: 'none' };
expect(lintDuplicateFieldLabels({ objects: [obj] })).toEqual([]);
});

it('reports a clash only a translation bundle creates, naming that locale alone', () => {
const stack = {
objects: [
{ name: 'deal', fields: { amount: { type: 'currency', label: 'Amount' }, total: { type: 'currency', label: 'Total' } } },
],
translations: [
{ 'ja-JP': { objects: { deal: { fields: { amount: { label: '金額' }, total: { label: '金額' } } } } } },
],
};
const findings = lintDuplicateFieldLabels(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain("'amount' and 'total'");
expect(findings[0].message).toContain('"金額" when read in locale ja-JP,');
});

it('reports an authored clash once, naming no locale', () => {
const stack = {
objects: [
{ name: 'deal', fields: { stage: { type: 'text', label: 'Status' }, status: { type: 'select', label: ' Status ' } } },
],
};
const findings = lintDuplicateFieldLabels(stack);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain("'stage' and 'status' under one label \"Status\", so");
});

it('reads an authored owner_id with its own label in place of the injected column', () => {
const obj = {
name: 'e9gv_ticket',
fields: { owner_id: { type: 'lookup', reference: 'sys_user', label: '所有者' }, assignee: { type: 'user', label: '负责人' } },
};
expect(lintDuplicateFieldLabels({ objects: [obj] })).toEqual([]);
});

it('is a gating entry the three commands and the object publish door run', () => {
const entry = AUTHORING_RULES.find((r) => r.name === 'lintDuplicateFieldLabels');
expect(entry?.tier).toBe('gating');
expect(entry?.runtimeTypes).toEqual(['object']);
const stack = { objects: [ticketObject()] };
for (const command of ['validate', 'build', 'lint'] as const) {
const rules = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter((f) => f.rule === FIELD_LABEL_DUPLICATE);
expect(rules.map((f) => f.severity), command).toEqual(['error']);
}
});

it('the explanation names exactly the locales the platform translates the injected columns into', () => {
const text = explainRule(FIELD_LABEL_DUPLICATE)!.paragraphs.join(' ');
const named = new Set([...text.matchAll(/`([a-z]{2}(?:-[A-Z]{2})?)`/g)].map((m) => m[1]));
expect([...named].sort()).toEqual([...BUILTIN_SYSTEM_FIELD_LABEL_LOCALES].sort());
});
});
Loading
Loading