From 3721a301d683d9014551659a25042bc7dfe8e3db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:03:06 +0000 Subject: [PATCH 01/10] wip(lint): title-format-retired becomes a gating rule at the object write door Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 30 +++++++- packages/lint/src/index.ts | 1 + packages/lint/src/rule-explanations.ts | 8 +- .../lint/src/validate-record-title.test.ts | 61 ++++++++++----- packages/lint/src/validate-record-title.ts | 76 +++++++++++++------ 5 files changed, 130 insertions(+), 46 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9f774754e77..005aa4171c8 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -125,7 +125,7 @@ import { validateFlowTriggerReadiness, } from './validate-flow-trigger-readiness.js'; import { validateApprovalApprovers } from './validate-approval-approvers.js'; -import { validateRecordTitle } from './validate-record-title.js'; +import { validateRecordTitle, validateTitleFormatRetired } from './validate-record-title.js'; import { validateFieldConsumers } from './validate-field-consumers.js'; import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; @@ -1546,9 +1546,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['flow'], run: (stack) => validateApprovalApprovers(stack), }, - // ADR-0079 — `titleFormat` is retired in favour of `nameField`, and an object - // with no resolvable title ships records with no meaningful name. Advisory: - // auto-provision and the `Record #` floor keep it from ever being fatal. + // ADR-0079 — `titleFormat` is retired in favour of `nameField`, and the + // ruling of record on #29007 (6574393684, letter B) refuses it: an object + // definition that declares one is refused at the metadata save gate and by + // `os validate`. `gating` because a declared template titles nothing — the + // explicit or stamped `nameField` outranks it — so a save that accepts it is + // a silent no-op. The schema still parses the key, so a stored object that + // carries it keeps loading; the runtime gate charges a finding on the + // written item to the write itself (`runtime-gate.ts`, "Re-saving a row is + // writing it"), so the row is refused when it is next saved, and a draft + // save passes until it is published (#4463 D1). Pre-parse: the predicate + // reads one authored key, and the finding must survive an unrelated schema + // error elsewhere in the stack. + { + name: 'validateTitleFormatRetired', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-record-title.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], + run: (stack) => validateTitleFormatRetired(stack), + }, + // ADR-0079 — an object with no resolvable title ships records with no + // meaningful name. Advisory: auto-provision and the `Record #` floor + // keep it from ever being fatal. { name: 'validateRecordTitle', tier: 'advisory', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index de575fbc3ad..a7b85940fcb 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -235,6 +235,7 @@ export type { SourceStyleFinding, SourceStyleSeverity } from './validate-page-so export { validateRecordTitle, + validateTitleFormatRetired, TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE, } from './validate-record-title.js'; diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index eaa9868844a..f779c3df8e9 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3034,8 +3034,12 @@ const TITLE_FORMAT_RETIRED_EXPLANATION: RuleExplanation = { 'write; point `nameField` at it. A formula with `returnType: \'text\'` is title-eligible too and ' + 'stays legal for a title shown for display only: its value is computed on read and stored nowhere, ' + 'so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell ' + - 'against it. Then drop `titleFormat`, which this warning reports for as long as it is declared.', - 'A warning, not an error: the schema still parses the key, so existing metadata keeps loading.', + 'against it. Then drop `titleFormat`: an object definition that declares it is refused at the ' + + 'metadata save gate (an active save, a draft promotion, a restore) and by `os validate`, ' + + '`os build` and `os lint`.', + 'An error, not a warning, though the schema still parses the key: a stored object that carries it ' + + 'keeps loading and reading, and is refused only when it is next saved or validated. A draft ' + + 'save passes; its publish is refused.', ], }; diff --git a/packages/lint/src/validate-record-title.test.ts b/packages/lint/src/validate-record-title.test.ts index cc4ea39f33f..2795f9b97c8 100644 --- a/packages/lint/src/validate-record-title.test.ts +++ b/packages/lint/src/validate-record-title.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data'; import { validateRecordTitle as validateRecordTitleUnrecorded, + validateTitleFormatRetired as validateTitleFormatRetiredUnrecorded, TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE, } from './validate-record-title.js'; @@ -16,6 +17,16 @@ const validateRecordTitle: typeof validateRecordTitleUnrecorded = (...args) => { fired.push(...findings); return findings; }; +const validateTitleFormatRetired: typeof validateTitleFormatRetiredUnrecorded = (...args) => { + const findings = validateTitleFormatRetiredUnrecorded(...args); + fired.push(...findings); + return findings; +}; +/** Both record-title rules, as one stack sees them across the two registry entries. */ +const bothRules = (stack: Record) => [ + ...validateTitleFormatRetired(stack), + ...validateRecordTitle(stack), +]; /** One-object stack; `fields` is a name-keyed map (the parsed-config shape). */ function objStack(obj: Record) { @@ -45,21 +56,36 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { expect(findings).toHaveLength(0); }); - it('warns (not errors) when titleFormat is declared', () => { - const findings = validateRecordTitle( - objStack({ - titleFormat: '{{record.first}} {{record.last}}', - fields: { name: { type: 'text' } }, - }), - ); + it('REFUSES (errors) when titleFormat is declared — ruling of record 6574393684, letter B', () => { + const stack = objStack({ + titleFormat: '{{record.first}} {{record.last}}', + fields: { name: { type: 'text' } }, + }); + const findings = validateTitleFormatRetired(stack); expect(findings).toHaveLength(1); - expect(findings[0].severity).toBe('warning'); + expect(findings[0].severity).toBe('error'); expect(findings[0].rule).toBe(TITLE_FORMAT_RETIRED); expect(findings[0].where).toBe('object "invoice"'); expect(findings[0].path).toBe('objects[0]'); expect(findings[0].message).toContain('titleFormat is retired (ADR-0079)'); expect(findings[0].message).toContain('migrate to nameField'); expect(findings[0].hint).toContain('nameField'); + // The title pair is the other entry's: a resolvable object with a + // template draws nothing from `validateRecordTitle`. + expect(validateRecordTitle(stack)).toHaveLength(0); + }); + + it('a single-field and an expression-envelope template are refused alike', () => { + for (const titleFormat of ['{name}', { dialect: 'template', source: '{name} · {code}' }]) { + const findings = validateTitleFormatRetired(objStack({ titleFormat, fields: { name: { type: 'text' } } })); + expect(findings.map((f) => [f.rule, f.severity]), JSON.stringify(titleFormat)).toEqual([ + [TITLE_FORMAT_RETIRED, 'error'], + ]); + } + }); + + it('an object with no titleFormat draws nothing from the refusal (control)', () => { + expect(validateTitleFormatRetired(objStack({ nameField: 'name', fields: { name: { type: 'text' } } }))).toEqual([]); }); it('warns (not errors) when no title is resolvable (status: none)', () => { @@ -83,29 +109,28 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { }); it('reports BOTH rules when an object has titleFormat and no resolvable title', () => { - const findings = validateRecordTitle( + const findings = bothRules( objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } }, }), ); expect(findings).toHaveLength(2); - expect(findings.map((f) => f.rule).sort()).toEqual( - [TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE].sort(), + expect(findings.map((f) => [f.rule, f.severity]).sort()).toEqual( + [[TITLE_FORMAT_RETIRED, 'error'], [TITLE_UNRESOLVABLE, 'warning']].sort(), ); - expect(findings.every((f) => f.severity === 'warning')).toBe(true); }); it('an empty-string titleFormat is not flagged', () => { - const findings = validateRecordTitle( + const findings = bothRules( objStack({ titleFormat: '', fields: { name: { type: 'text' } } }), ); expect(findings).toHaveLength(0); }); it('a stack with no objects is clean', () => { - expect(validateRecordTitle({})).toHaveLength(0); - expect(validateRecordTitle({ objects: [] })).toHaveLength(0); + expect(bothRules({})).toHaveLength(0); + expect(bothRules({ objects: [] })).toHaveLength(0); }); }); @@ -115,7 +140,7 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { describe('[#22161] one-line fixes — title-format-retired and title-unresolvable', () => { it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { // A long object name, so the bound is not read on a short one alone. - validateRecordTitle({ + bothRules({ objects: [{ name: 'regional_enterprise_account_hierarchy_snapshot', titleFormat: '{{record.a}}', fields: {} }], }); const converted = fired.filter((f) => f.rule === TITLE_FORMAT_RETIRED || f.rule === TITLE_UNRESOLVABLE); @@ -128,7 +153,7 @@ describe('[#22161] one-line fixes — title-format-retired and title-unresolvabl }); it('each reads as one remedy', () => { - const findings = validateRecordTitle(objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } } })); + const findings = bothRules(objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } } })); const byRule = new Map(findings.map((f) => [f.rule, f.hint])); expect(byRule.get(TITLE_FORMAT_RETIRED)).toBe( "Set nameField: '' for one field; a composite title to import, sort or search by is a text " + @@ -148,6 +173,8 @@ describe('[#22161] one-line fixes — title-format-retired and title-unresolvabl 'an explicit `nameField` takes precedence', 'ADR-0079', 'the schema still parses the key', + 'refused at the metadata save gate', + '`os validate`', ], [TITLE_UNRESOLVABLE]: [ 'auto-provisions a primary', diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts index 9473d8f3a68..59e6a8986d4 100644 --- a/packages/lint/src/validate-record-title.ts +++ b/packages/lint/src/validate-record-title.ts @@ -14,20 +14,28 @@ import { recordsOf } from './object-graph.js'; * the MCP authoring surface, and hand-authoring all get the coverage cloud * graph-lint already has (the ADR-0078 "not cloud-only" principle): * - * - `title-format-retired` — the object declares a `titleFormat`. That field - * is a RENDER-ONLY template the server can neither return nor query; ADR-0079 - * retires it in favour of `nameField`. The schema still parses it (existing - * metadata keeps loading), so this is advisory, not an error. - * - `title-unresolvable` — `objectTitleCompleteness` reports `status: 'none'`: - * no `nameField`/`displayNameField` pointer and no title-eligible field to + * - `title-format-retired` ({@link validateTitleFormatRetired}) — the object + * declares a `titleFormat`. That field is a RENDER-ONLY template the server + * can neither return nor query; ADR-0079 retires it in favour of `nameField`, + * and an explicit or stamped `nameField` outranks it, so a declared template + * titles nothing. An ERROR, under the ruling of record on #29007 (6574393684, + * letter B): an object definition that declares it is refused at the + * metadata save gate (the runtime publish gate, which an active save, a draft + * promotion and a restore all run) and by `os validate` / `os build` / + * `os lint`. The schema still parses the key, so a stored object that carries + * it keeps loading and reading; it is refused only when it is next saved or + * validated. Its own registry entry, apart from the title pair below, + * because the two rules sit on different tiers. + * - `title-unresolvable` ({@link validateRecordTitle}) — + * `objectTitleCompleteness` reports `status: 'none'`: no + * `nameField`/`displayNameField` pointer and no title-eligible field to * derive one from. Records will have no meaningful title (the runtime falls * back to the auto-provisioned primary / `Record #` floor), so this is a - * warning, not an error — nothing is fully broken. - * - * Both are warnings: the auto-provision transform and the id floor mean a - * green build never ships a fully title-less object. Reuses the shared spec - * predicate (`@objectstack/spec/data` → display-name) so cloud and framework - * classify titles identically. + * warning, not an error — nothing is fully broken: the auto-provision + * transform and the id floor mean a green build never ships a fully + * title-less object. Reuses the shared spec predicate + * (`@objectstack/spec/data` → display-name) so cloud and framework classify + * titles identically. */ export const TITLE_FORMAT_RETIRED = 'title-format-retired'; @@ -36,7 +44,7 @@ export const TITLE_UNRESOLVABLE = 'title-unresolvable'; export type RecordTitleSeverity = 'error' | 'warning'; export interface RecordTitleFinding { - /** Always `warning` today — both rules are advisory (see module note). */ + /** `error` for `title-format-retired`, `warning` for `title-unresolvable` (see module note). */ severity: RecordTitleSeverity; /** Diagnostic rule id (registry entry), e.g. `title-format-retired`. */ rule: string; @@ -53,12 +61,16 @@ export interface RecordTitleFinding { type AnyRec = Record; /** - * Validate every object's record-title declaration. Returns the list of - * findings (empty = clean). Both rules are advisory (`warning`): the caller - * must never fail the build on them alone — auto-provision + the `Record #` - * floor guarantee a resolvable title at runtime. + * Refuse every object that declares a `titleFormat` (ADR-0079; the ruling of + * record on #29007). Returns one `error` per declaring object, empty when none + * does. The message and the fix line are the prescription the `titleFormat` + * describe carries: a single-field title is `nameField`; a composite title is a + * stored computed field `nameField` points at. + * + * Reads the authored key only, so it runs pre-parse: an empty string is no + * declaration (the schema refuses a blank template on its own). */ -export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { +export function validateTitleFormatRetired(stack: AnyRec): RecordTitleFinding[] { const findings: RecordTitleFinding[] = []; const objects = recordsOf(stack.objects); @@ -68,12 +80,9 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { const where = `object "${objName}"`; const path = `objects[${i}]`; - // ── (a) titleFormat is retired (ADR-0079) ── - // Render-only template the server cannot return or query. Still parsed by - // the schema for back-compat, so advisory. if (obj.titleFormat !== undefined && obj.titleFormat !== null && obj.titleFormat !== '') { findings.push({ - severity: 'warning', + severity: 'error', rule: TITLE_FORMAT_RETIRED, where, path, @@ -88,8 +97,29 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { `text field with an expression, stored on write (a text formula is display-only); drop titleFormat`, }); } + } + + return findings; +} + +/** + * Validate every object's record-title resolvability (`title-unresolvable`). + * Returns the list of findings (empty = clean). Advisory (`warning`): the + * caller must never fail the build on it alone — auto-provision + the + * `Record #` floor guarantee a resolvable title at runtime. A declared + * `titleFormat` is {@link validateTitleFormatRetired}'s, not this rule's. + */ +export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { + const findings: RecordTitleFinding[] = []; + + const objects = recordsOf(stack.objects); + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const where = `object "${objName}"`; + const path = `objects[${i}]`; - // ── (b) no resolvable title (status: 'none') ── + // ── no resolvable title (status: 'none') ── // Reuse the shared spec predicate so cloud graph-lint and framework lint // classify titles identically. `none` = no pointer AND nothing derivable. const completeness = objectTitleCompleteness(obj as DisplayNameObjectMeta); From ee1b826110e7719fc80fe63effe4cf5e1b26f191 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:05:37 +0000 Subject: [PATCH 02/10] wip: system objects drop the retired titleFormat for nameField Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- .../src/audit/sys-attachment.object.ts | 5 ++- .../src/audit/sys-email-template.object.ts | 1 - .../src/audit/sys-email.object.ts | 1 - .../src/audit/sys-identity-email.object.ts | 1 - .../src/audit/sys-import-job.object.ts | 1 - .../src/audit/sys-job-queue.object.ts | 1 - .../src/audit/sys-job-run.object.ts | 1 - .../src/audit/sys-job.object.ts | 1 - .../src/audit/sys-notification.object.ts | 1 - .../src/identity/sys-account.object.ts | 10 +++--- .../src/identity/sys-api-key.object.ts | 1 - .../sys-business-unit-member.object.ts | 17 ++++------ .../src/identity/sys-business-unit.object.ts | 1 - .../src/identity/sys-device-code.object.ts | 1 - .../src/identity/sys-invitation.object.ts | 10 +++--- .../src/identity/sys-member.object.ts | 15 ++++---- .../identity/sys-oauth-application.object.ts | 1 - .../src/identity/sys-organization.object.ts | 1 - .../sys-scim-connection-binding.object.ts | 1 - .../sys-scim-connection-credential.object.ts | 1 - .../identity/sys-scim-group-member.object.ts | 15 ++++---- .../src/identity/sys-scim-group.object.ts | 1 - .../sys-scim-identity-tombstone.object.ts | 1 - .../sys-scim-projection-grant.object.ts | 15 ++++---- .../src/identity/sys-scim-subject.object.ts | 11 +++--- .../src/identity/sys-scim-user.object.ts | 1 - .../src/identity/sys-session.object.ts | 1 - .../src/identity/sys-sso-provider.object.ts | 1 - .../src/identity/sys-team-member.object.ts | 15 ++++---- .../src/identity/sys-team.object.ts | 1 - .../src/identity/sys-two-factor.object.ts | 15 ++++---- .../identity/sys-user-preference.object.ts | 1 - .../src/identity/sys-user.object.ts | 1 - .../src/identity/sys-verification.object.ts | 10 +++--- .../src/system/sys-exchange-rate.object.ts | 1 - .../system/sys-migration-journal.object.ts | 1 - .../src/system/sys-migration.object.ts | 1 - .../src/system/sys-platform-setting.object.ts | 1 - .../src/system/sys-setting.object.ts | 1 - .../src/system/sys-trusted-key.object.ts | 1 - .../src/sys-approval-action.object.ts | 10 +++--- .../src/sys-approval-approver.object.ts | 10 +++--- .../src/sys-approval-delegation.object.ts | 8 ++--- .../src/sys-approval-request.object.ts | 34 +++++++++---------- .../src/objects/sys-activity.object.ts | 1 - .../src/objects/sys-comment.object.ts | 1 - .../sys-audience-binding-suggestion.object.ts | 6 +++- .../objects/sys-user-permission-set.object.ts | 10 +++--- .../src/objects/sys-user-position.object.ts | 8 ++--- .../objects/sys-assignment-cursor.object.ts | 1 - .../src/objects/sys-queue-member.object.ts | 1 - .../src/objects/sys-queue.object.ts | 1 - .../src/objects/sys-share-link.object.ts | 7 ++-- .../src/objects/sys-sharing-rule.object.ts | 1 - .../plugin-webhooks/src/sys-webhook.object.ts | 1 - .../src/sys-automation-run.object.ts | 12 +++---- .../objects/sys-mail-association.object.ts | 1 - .../src/objects/sys-mail-message.object.ts | 1 - .../src/objects/sys-mailbox.object.ts | 1 - .../src/objects/http-delivery.object.ts | 10 +++--- .../src/objects/inbox-message.object.ts | 1 - .../objects/notification-delivery.object.ts | 9 +++-- .../objects/notification-preference.object.ts | 9 ++--- .../objects/notification-receipt.object.ts | 8 ++--- .../objects/notification-template.object.ts | 6 +++- .../src/objects/sys-presence.object.ts | 9 +++-- .../src/objects/system-file.object.ts | 1 - .../objects/system-upload-session.object.ts | 1 - 68 files changed, 146 insertions(+), 190 deletions(-) diff --git a/packages/platform-objects/src/audit/sys-attachment.object.ts b/packages/platform-objects/src/audit/sys-attachment.object.ts index b86b64f4e54..5db8da91877 100644 --- a/packages/platform-objects/src/audit/sys-attachment.object.ts +++ b/packages/platform-objects/src/audit/sys-attachment.object.ts @@ -33,7 +33,10 @@ export const SysAttachment = ObjectSchema.create({ isSystem: true, managedBy: 'platform', description: 'Polymorphic link between a sys_file and any other record', - titleFormat: '{file_name} → {parent_object}/{parent_id}', + // [ADR-0079] The record title: the file's name, the field the registry + // stamped when the retired `titleFormat` template stood here (an object + // definition that declares one is refused at save and by `os validate`). + nameField: 'file_name', highlightFields: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'], fields: { diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index f3809d9378a..01740512c52 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -34,7 +34,6 @@ export const SysEmailTemplate = ObjectSchema.create({ description: 'Outbound email template (subject + body + variables) resolved by name+locale', displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'label', 'category', 'locale', 'active'], fields: { diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 5a3beae402b..e013ce94b74 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -28,7 +28,6 @@ export const SysEmail = ObjectSchema.create({ description: 'Outbound email delivery log', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], fields: { diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts index ccb913bfefd..47d778ac470 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -48,7 +48,6 @@ export const SysIdentityEmail = ObjectSchema.create({ description: 'Outbound identity mail log — verification, password reset and sign-in mail about a person, not an organization', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], // `sys_email`'s fields, written out: the source scans that judge an diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 53c3a19151c..5c63f05bc44 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -29,7 +29,6 @@ export const SysImportJob = ObjectSchema.create({ description: 'Asynchronous bulk-import job state, progress, and history', displayNameField: 'object_name', nameField: 'object_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{object_name} import @ {created_at}', highlightFields: ['object_name', 'status', 'processed_rows', 'total_rows', 'created_at'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index b145a722d9e..ba1d05c4fe2 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -96,7 +96,6 @@ export const SysJobQueue = ObjectSchema.create({ description: 'Durable job/message queue including dead letters', displayNameField: 'queue', nameField: 'queue', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{queue} #{id}', highlightFields: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index f55bb34903d..50e726d43ca 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -42,7 +42,6 @@ export const SysJobRun = ObjectSchema.create({ description: 'Background job execution history', displayNameField: 'job_name', nameField: 'job_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{job_name} @ {started_at}', highlightFields: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index a9d9fa6788d..236e6a4ae6d 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -38,7 +38,6 @@ export const SysJob = ObjectSchema.create({ description: 'Catalogue of registered background jobs', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'], fields: { diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index c43614f2c2c..24683f84561 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -44,7 +44,6 @@ export const SysNotification = ObjectSchema.create({ description: 'Every notification the platform sent, one row per event.', displayNameField: 'topic', nameField: 'topic', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{topic}', highlightFields: ['topic', 'severity', 'source_object', 'created_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 33b605b1468..e9f3560fe69 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -28,15 +28,15 @@ export const SysAccount = ObjectSchema.create({ }, description: 'OAuth and authentication provider accounts', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{provider_id} - {account_id}', highlightFields: ['provider_id', 'user_id', 'account_id'], // Custom actions — sysadmins routinely need to revoke a user's OAuth diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 8378f017b4e..d08f60a3d92 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -77,7 +77,6 @@ export const SysApiKey = ObjectSchema.create({ description: 'API keys for programmatic access', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'prefix', 'user_id', 'expires_at', 'revoked'], // Custom actions — sys_api_key is managed-by 'better-auth' but the diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index 618c866da11..87a45d77b98 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -24,20 +24,17 @@ export const SysBusinessUnitMember = ObjectSchema.create({ managedBy: 'platform', description: 'User assignment to a business unit (matrix-org friendly, effective-dated).', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first, and is the - // rung below the pointer where the title has no value: the console draws a - // lookup placeholder by the related record's name (else its email), so over - // an expanded row it renders the same text the formula does. - // `identity-display-title.test.ts` holds the two to the same text; - // `plugin-security/src/business-unit-member-title.test.ts` pins the title a - // read serves. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it, the rung below the pointer where + // the title had no value, is gone (refused at save and by `os validate`). + // `identity-display-title.test.ts` holds the formula to the text that + // template drew; `plugin-security/src/business-unit-member-title.test.ts` + // pins the title a read serves. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} in {business_unit_id}', highlightFields: ['user_id', 'business_unit_id', 'function_in_business_unit', 'is_primary'], fields: { diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index fc041d5a106..0706460c0cd 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -34,7 +34,6 @@ export const SysBusinessUnit = ObjectSchema.create({ description: 'Your company structure — divisions, departments, regions and offices — used to scope data and reports.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index dd80c97be38..b5f2291f1f2 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -48,7 +48,6 @@ export const SysDeviceCode = ObjectSchema.create({ }, description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) pending requests', nameField: 'user_code', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{user_code}', highlightFields: ['user_code', 'status', 'client_id', 'expires_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 01eee93d51f..bc61c78d037 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -49,18 +49,18 @@ export const SysInvitation = ObjectSchema.create({ }, description: 'Organization invitations for user onboarding', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) // Title by invitee email rather than organization_id: the latter is null in // single-org mode (renders "Invitation to null"), and the recipient email is // the more useful identifier in both modes anyway. - titleFormat: 'Invitation for {email}', highlightFields: ['email', 'organization_id', 'status'], // Custom actions — generic CRUD is suppressed (better-auth-managed). diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 8407a7f5b83..27c5d3d4dfc 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -35,21 +35,20 @@ export const SysMember = ObjectSchema.create({ }, description: 'Organization membership records', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. - // `user_id` is a lookup, so the formula reads its stored id: a formula - // is evaluated on the stored row, before `$expand`, and cannot reach the - // related record's own title. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `user_id` is a lookup, so the formula reads its + // stored id: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach the related record's own title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) // Org-independent title: organization_id is null in single-org mode, so a // '{user_id} in {organization_id}' format renders "… in null". User + role // identifies the membership in both single- and multi-org deployments. - titleFormat: '{user_id} ({role})', highlightFields: ['user_id', 'organization_id', 'role'], // Row-level actions: better-auth `organization/update-member-role` and diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 57e34de59fb..37a63b9fd65 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -35,7 +35,6 @@ export const SysOauthApplication = ObjectSchema.create({ description: 'Registered OAuth/OIDC client applications', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'client_id', 'type', 'disabled'], // Custom actions — all OAuth-application mutations are routed through diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 541b3c2b7d2..243be28b896 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -52,7 +52,6 @@ export const SysOrganization = ObjectSchema.create({ description: 'Organizations for multi-tenant grouping', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'slug'], // Custom actions — generic create / delete are suppressed (better-auth- diff --git a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts index 510b3d9ed7a..474eed11b8d 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts @@ -36,7 +36,6 @@ export const SysScimConnectionBinding = ObjectSchema.create({ description: 'Lifecycle state for a SCIM provisioning connection, including its decommission state machine', displayNameField: 'connection_id', nameField: 'connection_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{connection_id}', highlightFields: ['connection_id', 'provisioning_domain_id', 'decommission_status'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts index fb15ba32470..091fd7dd1c3 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts @@ -53,7 +53,6 @@ export const SysScimConnectionCredential = ObjectSchema.create({ description: 'Bearer credentials (one-way digests) that authenticate SCIM provisioning connections', displayNameField: 'connection_id', nameField: 'connection_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{connection_id}', highlightFields: ['connection_id', 'label', 'active', 'expires_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts index d27e840feb0..ce5dc7b8b3b 100644 --- a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts @@ -31,18 +31,17 @@ export const SysScimGroupMember = ObjectSchema.create({ }, description: 'SCIM group membership rows pushed by the IdP (group ↔ provisioned user)', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. - // `scim_user_id` and `group_id` are lookups, so the formula reads their stored - // ids: a formula is evaluated on the stored row, before `$expand`, and - // cannot reach a related record's own title. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `scim_user_id` and `group_id` are lookups, so the + // formula reads their stored ids: a formula is evaluated on the stored row, + // before `$expand`, and cannot reach a related record's own title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{scim_user_id} in {group_id}', highlightFields: ['group_id', 'scim_user_id', 'created_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-group.object.ts b/packages/platform-objects/src/identity/sys-scim-group.object.ts index 4842d5a2605..f9142bbff96 100644 --- a/packages/platform-objects/src/identity/sys-scim-group.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group.object.ts @@ -35,7 +35,6 @@ export const SysScimGroup = ObjectSchema.create({ description: 'IdP-pushed SCIM 2.0 groups, scoped per provisioning connection', displayNameField: 'display_name', nameField: 'display_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{display_name}', highlightFields: ['display_name', 'connection_id', 'updated_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts index 3689408974c..1215a492943 100644 --- a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts @@ -31,7 +31,6 @@ export const SysScimIdentityTombstone = ObjectSchema.create({ description: 'Tombstones of IdP-deleted SCIM identities, kept so a re-provision is recognized', displayNameField: 'external_id', nameField: 'external_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{external_id}', highlightFields: ['external_id', 'connection_id', 'deleted_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts index 71d597447d0..bfd78c2786e 100644 --- a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts @@ -32,18 +32,17 @@ export const SysScimProjectionGrant = ObjectSchema.create({ }, description: 'Role/entitlement grants projected onto platform users by SCIM provisioning', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. - // `user_id` is a lookup, so the formula reads its stored id: a formula - // is evaluated on the stored row, before `$expand`, and cannot reach the - // related record's own title. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `user_id` is a lookup, so the formula reads its + // stored id: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach the related record's own title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{role} → {user_id}', highlightFields: ['role', 'source_kind', 'user_id', 'connection_id'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-subject.object.ts b/packages/platform-objects/src/identity/sys-scim-subject.object.ts index 30e8e4deaa9..afddcb39ab7 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -30,15 +30,14 @@ export const SysScimSubject = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Per-user SCIM provisioning link — one row per user any SCIM connection provisions', - // [ADR-0079] Single-field title: `titleFormat` names one field, so the - // pointer names that field directly, as the `titleFormat` describe - // prescribes (the same shape as `sys_session`). With no pointer declared, - // the registry's designate-only pass stamped `nameField: 'id'`, so a - // renderer honouring ADR-0079's order drew the raw id as the H1. + // [ADR-0079] Single-field title: the retired `titleFormat` named one field, + // so the pointer names that field directly, as the `titleFormat` describe + // prescribes (the same shape as `sys_session`). With no pointer declared, the + // registry's designate-only pass stamped `nameField: 'id'`, so a renderer + // honouring ADR-0079's order drew the raw id as the H1. // `identity-display-title.test.ts` pins the pointer. displayNameField: 'user_id', nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{user_id}', highlightFields: ['user_id', 'profile_source_id', 'updated_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-user.object.ts b/packages/platform-objects/src/identity/sys-scim-user.object.ts index 6e657e220d4..19b70f07379 100644 --- a/packages/platform-objects/src/identity/sys-scim-user.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-user.object.ts @@ -36,7 +36,6 @@ export const SysScimUser = ObjectSchema.create({ description: 'Per-connection projection of an IdP-provisioned user (SCIM 2.0 /Users)', displayNameField: 'user_name', nameField: 'user_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{user_name}', highlightFields: ['user_name', 'display_name', 'primary_email', 'active'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 705ab266175..37a21f4f020 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -63,7 +63,6 @@ export const SysSession = ObjectSchema.create({ description: 'Active user sessions', displayNameField: 'user_id', nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: 'Session — {user_id}', highlightFields: ['user_id', 'ip_address', 'expires_at'], // Custom actions — sessions are managed by better-auth (generic CRUD diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 88bb9c49264..e54390e673c 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -67,7 +67,6 @@ export const SysSsoProvider = ObjectSchema.create({ description: 'External SSO identity providers (OIDC / SAML) this system federates login to', displayNameField: 'provider_id', nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{provider_id}', highlightFields: ['provider_id', 'issuer', 'domain'], // All mutations go through @better-auth/sso's endpoints under diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index 6309ed8bc46..1fce5098e50 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -28,18 +28,17 @@ export const SysTeamMember = ObjectSchema.create({ }, description: 'Team membership records linking users to teams', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. - // `user_id` and `team_id` are lookups, so the formula reads their stored - // ids: a formula is evaluated on the stored row, before `$expand`, and - // cannot reach a related record's own title. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `user_id` and `team_id` are lookups, so the + // formula reads their stored ids: a formula is evaluated on the stored row, + // before `$expand`, and cannot reach a related record's own title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} in {team_id}', highlightFields: ['user_id', 'team_id', 'created_at'], // Custom actions calling better-auth's team-member endpoints. Generic diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 5f86d6baa4c..0199ba88f2d 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -28,7 +28,6 @@ export const SysTeam = ObjectSchema.create({ description: 'Teams within organizations for fine-grained grouping', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'organization_id'], // Custom actions calling better-auth's team endpoints. Generic CRUD is diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index fceb27b7130..0f680039ee3 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -28,18 +28,17 @@ export const SysTwoFactor = ObjectSchema.create({ }, description: 'Two-factor authentication credentials', // [ADR-0079] The record title is `display_title`, a text formula over the - // columns `titleFormat` names. With no pointer declared, the registry's + // columns `titleFormat` named. With no pointer declared, the registry's // designate-only pass stamped `nameField: 'id'` (the first title-eligible // field), so a renderer honouring ADR-0079's order (an explicit `nameField` - // wins over `titleFormat`) drew the raw id as the record page's H1. - // `titleFormat` stays for renderers that still read it first; - // `identity-display-title.test.ts` holds the two to the same text. - // `user_id` is a lookup, so the formula reads its stored id: a formula - // is evaluated on the stored row, before `$expand`, and cannot reach the - // related record's own title. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `user_id` is a lookup, so the formula reads its + // stored id: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach the related record's own title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: 'Two-factor for {user_id}', highlightFields: ['user_id', 'created_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index bbafb52f5f8..113760f0252 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -30,7 +30,6 @@ export const SysUserPreference = ObjectSchema.create({ managedBy: 'system-data', description: 'Per-user key-value preferences (theme, locale, etc.)', nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{key}', highlightFields: ['user_id', 'key'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 10c4492a1d9..120e73b71bb 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -86,7 +86,6 @@ export const SysUser = ObjectSchema.create({ description: 'User accounts for authentication', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'email', 'email_verified'], // Custom actions — generic CRUD is suppressed because user accounts are diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 2c7ca3fb2f8..93946abbb5a 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -37,9 +37,10 @@ export const SysVerification = ObjectSchema.create({ // pointer declared, the registry's designate-only pass stamped // `nameField: 'id'` (the first title-eligible field), so a renderer honouring // ADR-0079's order (an explicit `nameField` wins over `titleFormat`) drew the - // raw id as the record page's H1. `titleFormat` stays for renderers that - // still read it first; `identity-display-title.test.ts` holds the two to the - // same text. + // raw id as the record page's H1. The retired `titleFormat` template beside + // it is gone (refused at save and by `os validate`); + // `identity-display-title.test.ts` holds the formula to the text that + // template drew. // // [#21197] The title is a FIXED label, and neither it nor the highlights may // name `identifier` or `value`. Both columns are `internal` (below), and the @@ -50,7 +51,6 @@ export const SysVerification = ObjectSchema.create({ // own name rather than a value. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: 'Verification', highlightFields: ['expires_at', 'created_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy @@ -76,7 +76,7 @@ export const SysVerification = ObjectSchema.create({ // [ADR-0079] The record title (`nameField` above). A formula is computed on // read and has no stored column. [#21197] A fixed label: see the note on - // `titleFormat` for why it reads no column. + // `nameField` for why it reads no column. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index bd478ea8fd1..a797b7075c9 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -85,7 +85,6 @@ export const SysExchangeRate = ObjectSchema.create({ 'the rates foreign-currency amounts are converted to the home currency with.', displayNameField: 'currency', nameField: 'currency', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{currency} · {effective_date}', highlightFields: ['currency', 'rate', 'effective_date', 'source'], fields: { diff --git a/packages/platform-objects/src/system/sys-migration-journal.object.ts b/packages/platform-objects/src/system/sys-migration-journal.object.ts index 45288286637..a6cdf1bf632 100644 --- a/packages/platform-objects/src/system/sys-migration-journal.object.ts +++ b/packages/platform-objects/src/system/sys-migration-journal.object.ts @@ -80,7 +80,6 @@ export const SysMigrationJournal = ObjectSchema.create({ description: 'Append-only trace of migration runs: which chunks committed, which were compensated, and where a killed run stopped.', nameField: 'run_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{run_id} #{seq} {kind}', highlightFields: ['run_id', 'seq', 'kind', 'chunk_index', 'created_at'], listViews: { recent: { diff --git a/packages/platform-objects/src/system/sys-migration.object.ts b/packages/platform-objects/src/system/sys-migration.object.ts index 5bbd2ad5b3b..7cf9a21ccb3 100644 --- a/packages/platform-objects/src/system/sys-migration.object.ts +++ b/packages/platform-objects/src/system/sys-migration.object.ts @@ -64,7 +64,6 @@ export const SysMigration = ObjectSchema.create({ requiredPermissions: ['manage_platform_settings'], description: 'Deployment-level data-migration flags: which gated data migrations ran here and whether their self-check passed.', nameField: 'id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{id}', highlightFields: ['id', 'blocking', 'verified_at', 'last_run_at'], fields: { diff --git a/packages/platform-objects/src/system/sys-platform-setting.object.ts b/packages/platform-objects/src/system/sys-platform-setting.object.ts index 3da60fe30e7..6ddbb74d22c 100644 --- a/packages/platform-objects/src/system/sys-platform-setting.object.ts +++ b/packages/platform-objects/src/system/sys-platform-setting.object.ts @@ -58,7 +58,6 @@ export const SysPlatformSetting = ObjectSchema.create({ description: 'Deployment-wide settings values: the global rung of the settings cascade.', displayNameField: 'key', nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{namespace}.{key}', highlightFields: ['namespace', 'key', 'updated_at'], fields: { diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 98b328f7bd8..e47f751b466 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -49,7 +49,6 @@ export const SysSetting = ObjectSchema.create({ description: 'Generic K/V store backing the SettingsManifest contract.', displayNameField: 'key', nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{namespace}.{key}', highlightFields: ['namespace', 'key', 'scope', 'updated_at'], listViews: { diff --git a/packages/platform-objects/src/system/sys-trusted-key.object.ts b/packages/platform-objects/src/system/sys-trusted-key.object.ts index 2f375c23092..9ec5035c56c 100644 --- a/packages/platform-objects/src/system/sys-trusted-key.object.ts +++ b/packages/platform-objects/src/system/sys-trusted-key.object.ts @@ -65,7 +65,6 @@ export const SysTrustedKey = ObjectSchema.create({ description: "Publisher public keys this environment accepts a plugin artifact's signature from. Public keys only; written by `os plugin trust`.", nameField: 'key_id', - titleFormat: '{key_id}', highlightFields: ['key_id', 'status', 'source', 'fingerprint', 'added_at'], listViews: { all: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index a39efcdfa07..e75321dd083 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -24,14 +24,14 @@ export const SysApprovalAction = ObjectSchema.create({ managedBy: 'append-only', description: 'Append-only audit trail for approval actions', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw id. `titleFormat` - // stays for renderers that still read it first; - // `sys-approval-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-approval-display-title.test.ts` holds the formula to + // the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{action} · {step_name}', highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'acted_as', 'via_override', 'created_at'], // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds diff --git a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts index 7a0ff7e5140..24a51e3a653 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts @@ -34,14 +34,14 @@ export const SysApprovalApprover = ObjectSchema.create({ managedBy: 'engine-owned', description: 'Normalized pending-approver rows for indexed inbox queries', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw id. `titleFormat` - // stays for renderers that still read it first; - // `sys-approval-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-approval-display-title.test.ts` holds the formula to + // the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{approver} · {request_id}', highlightFields: ['request_id', 'approver', 'created_at'], fields: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 7b41d1e796c..063e30c6bbe 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -50,15 +50,15 @@ export const SysApprovalDelegation = ObjectSchema.create({ // membership-level approvers are not (`ApprovalService.expandApprovers`). description: 'While someone is away, approvals assigned to them personally go to their delegate.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first // title-eligible field), so a renderer honouring ADR-0079's order (an // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-approval-delegation-display-title.test.ts` holds the two to the same text. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-approval-delegation-display-title.test.ts` + // holds the formula to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{delegator_id} → {delegate_id}', highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'], listViews: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 15889204166..5362d99dde9 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -47,29 +47,27 @@ export const SysApprovalRequest = ObjectSchema.create({ description: 'Live approval instance tracked per submission', // [ADR-0079, ADR-0085] The record title is the flow's label, `process_label`: // a STORED column, so every door that serves the row serves its title. The - // approvals get (`rowFromRequest`) serves it by the display-label chain — - // the column, else the config snapshot's label, else the humanized machine - // name — so the request page reached through that get always has a title, - // and it is never `flow:NAME`. The pointer used to be `display_title`, a - // formula: a formula has no column, the approvals get served no value for - // it, and the page fell through to `titleFormat`, the machine name and the - // raw subject record id. + // approvals get (`rowFromRequest`) serves it by the display-label chain — the + // column, else the config snapshot's label, else the humanized machine name — + // so the request page reached through that get always has a title, and it is + // never `flow:NAME`. The pointer used to be `display_title`, a formula: a + // formula has no column, the approvals get served no value for it, and the + // page fell through to the since-retired `titleFormat`, the machine name and + // the raw subject record id. // // The title names no subject record. The request page names the subject on // the card the `record_id` / `object_name` pair draws in the Details tab, // which says so when the reader may not read the record or it was deleted; // a title cannot, so a bare record id is never shown there. // - // `titleFormat` is the deprecated fallback a renderer reaches only where - // `process_label` is empty: a row written before the column, until the boot - // fill labels it (`display-label-backfill.ts`). It names `{process_name}`, - // the machine source, because a placeholder template cannot say "else" — - // the same fallback `display_title` draws, so every door agrees on such a - // row too. `sys-approval-display-title.test.ts` and - // `sys-approval-request-title.integration.test.ts` pin both. + // Where `process_label` is empty — a row written before the column, until the + // boot fill labels it (`display-label-backfill.ts`) — the retired + // `titleFormat` template `{process_name}` drew the machine source; it is gone + // (refused at save and by `os validate`), and `display_title` draws that same + // fallback. `sys-approval-display-title.test.ts` and + // `sys-approval-request-title.integration.test.ts` pin the title. displayNameField: 'process_label', nameField: 'process_label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{process_name}', // [ADR-0085] The flow is named by its label, `process_label`, never by the // machine source `process_name` (`flow:NAME`), and the step by its label, // `current_step_label`, never by the node id `current_step`, as the request @@ -179,9 +177,9 @@ export const SysApprovalRequest = ObjectSchema.create({ // [ADR-0079] The title text the data API computes on read: the flow's // label, `process_label`, else the machine source `process_name` (required) // where no label is stored — the text the title ladder draws from - // `nameField` and `titleFormat` above, on every row the engine writes (a - // writer stores a label or null, never a blank). A formula has no - // stored column. It names no subject record, as the title does not. + // `nameField` above, on every row the engine writes (a writer stores a + // label or null, never a blank). A formula has no stored column. It names + // no subject record, as the title does not. // // It is no longer the title pointer (the approvals get serves no formula // value), and it stays declared because stored activity rows name it: the diff --git a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts index d383d1580d9..34bc4a358e4 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts @@ -35,7 +35,6 @@ export const SysActivity = ObjectSchema.create({ description: 'Recent activity stream entries (lightweight, denormalized)', displayNameField: 'summary', nameField: 'summary', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{type} · {summary}', highlightFields: ['timestamp', 'type', 'actor_name', 'summary'], fields: { diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts index 62bbb7b149b..d3b1aa09969 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts @@ -134,7 +134,6 @@ export const SysComment = ObjectSchema.create({ description: 'Threaded comments attached to records via thread_id', displayNameField: 'body', nameField: 'body', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{author_name}: {body}', highlightFields: ['created_at', 'author_name', 'body'], fields: { diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts index a85ad1f5c3f..6b19c9b690b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -39,7 +39,11 @@ export const SysAudienceBindingSuggestion = ObjectSchema.create({ // [ADR-0090 D5/D9] The everyone-anchor suggestion and its deployment-level // decision, in the words Setup shows. description: 'A package\'s suggestion to give one of its permission sets to everyone who signs in, and the decision taken on it.', - titleFormat: '{package_id}: {permission_set_name} → {anchor}', + // [ADR-0079] The record title: the suggested permission set, the field the + // registry stamped when the retired `titleFormat` template stood here (an + // object definition that declares one is refused at save and by + // `os validate`). + nameField: 'permission_set_name', highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'], fields: { diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index de72608c5c1..f0d14450552 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -40,13 +40,13 @@ export const SysUserPermissionSet = ObjectSchema.create({ // user and the set. With no pointer declared, the registry's designate-only // pass stamped `nameField: 'id'` (the first title-eligible field), so a // renderer honouring ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`) drew the raw id as the record page's H1. `titleFormat` is - // the rung below the pointer: a renderer reaches it only where the title has - // no value (the user is not readable by this reader, or no longer exists). + // `titleFormat`) drew the raw id as the record page's H1. The retired + // `titleFormat` template, the rung below the pointer where the title has no + // value (the user is not readable by this reader, or no longer exists), is + // gone (refused at save and by `os validate`). // `sys-user-permission-set-person-title.test.ts` pins the title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} → {permission_set}', highlightFields: ['user_id', 'permission_set', 'organization_id'], fields: { @@ -66,7 +66,7 @@ export const SysUserPermissionSet = ObjectSchema.create({ // `sys_user` applies, so the title carries nothing that reader could not // read on the user itself: a user the reader may not read, or one that no // longer exists, leaves the title null — never a value computed in its - // place — and a renderer falls to `titleFormat` below it. + // place — and a renderer falls to its derivation below it. // `user_id` is read only through the hop (a bare read beside a traversal // is refused, `traversal-bare-and-traversed`). Both columns are required // (the set by name, ADR-0131 D4); a legacy grant the v18 ceremony's diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index f86a671b4b2..a6bdb65122b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -46,15 +46,15 @@ export const SysUserPosition = ObjectSchema.create({ // words Setup shows. description: 'Which positions each user holds, one row per assignment.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first // title-eligible field), so a renderer honouring ADR-0079's order (an // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-security-assignment-display-title.test.ts` holds the two to the same text. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-security-assignment-display-title.test.ts` + // holds the formula to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} → {position}', highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], fields: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts index cf8ef91b3cb..2d40cfbe1c4 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts @@ -29,7 +29,6 @@ export const SysAssignmentCursor = ObjectSchema.create({ description: 'Where round-robin assignment last left off, per assignment rule entry.', displayNameField: 'rule', nameField: 'rule', - titleFormat: '{rule} / {entry}', highlightFields: ['rule', 'entry', 'last_user_id', 'version'], fields: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts index 9c5e59e041a..a8da1d0f903 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts @@ -29,7 +29,6 @@ export const SysQueueMember = ObjectSchema.create({ description: 'Who belongs to a queue: a user, a team, a position or a business unit.', displayNameField: 'queue', nameField: 'queue', - titleFormat: '{queue}', highlightFields: ['queue', 'recipient_type', 'recipient_id'], listViews: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts index 6b9bcb729d1..227b9cdba0e 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts @@ -32,7 +32,6 @@ export const SysQueue = ObjectSchema.create({ description: 'A named group of people that unassigned records can wait in until one of them takes the record.', displayNameField: 'name', nameField: 'name', - titleFormat: '{label}', highlightFields: ['name', 'label'], listViews: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index cf003319238..5528f58979a 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -50,12 +50,11 @@ export const SysShareLink = ObjectSchema.create({ description: 'Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.', // [#24611] The record title is the link's own label, the free text its // creator gave it. Declared rather than left to the registry's designation, - // which derives the same field today. A link created without a label is - // titled by `titleFormat`: what the link grants and to whom, each value drawn - // as its option label on the record page. Neither title names the shared + // which derives the same field today. The retired `titleFormat` template that + // titled a link created without a label (what it grants and to whom) is gone + // (refused at save and by `os validate`). The title never names the shared // record's object; the Details tab's pointer card names it by its label. nameField: 'label', - titleFormat: '{permission} · {audience}', // Neither half of the pointer pair is listed, as on `sys_record_share`: the // strip would draw the type half as an API name and the id half as a raw id, // and would hide `record_id` from the Details tab, where it draws as the diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 052f73f76d0..72e80666279 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -40,7 +40,6 @@ export const SysSharingRule = ObjectSchema.create({ description: 'Grants a group of people access to the records that match a condition.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active'], listViews: { diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index ef5016938f1..59e16bfad6e 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -62,7 +62,6 @@ export const SysWebhook = ObjectSchema.create({ description: 'An outbound HTTP webhook your organization creates here or through the API. Webhooks that come built into an app are not listed here. Both kinds are sent through the same delivery queue.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'object_name', 'url', 'active', 'updated_at'], listViews: { diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index da00f0d32b5..577731c65da 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -73,14 +73,14 @@ export const SysAutomationRun = ObjectSchema.create({ // refused / missed), aged out by the retention above. description: 'Paused automation runs waiting to resume, and the recent history of finished runs.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw run id. - // `titleFormat` stays for renderers that still read it first; - // `sys-automation-run-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw run id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-automation-run-display-title.test.ts` holds the + // formula to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{flow_name} · {node_id}', // The three operands of the broken-sweep first filter — // `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0` — sit in // the highlight set on purpose (#4354): "selected 30, acted 0" has to be @@ -176,7 +176,7 @@ export const SysAutomationRun = ObjectSchema.create({ // consumed snapshot's node from it — there is no other column), so on that // one terminal-row class the writer (`AutomationEngine.recordLog`) puts // the PAUSED node here, not the last step. This column is also what the - // Runs surface titles and highlights a row with (`titleFormat` / + // Runs surface titles and highlights a row with (`display_title` / // `highlightFields` above), so the carve-out is visible there too, by // design: the row names the pause an operator can re-arm. node_id: Field.text({ diff --git a/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts index bf55c2afaf3..a90787f5609 100644 --- a/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts @@ -32,7 +32,6 @@ export const SysMailAssociation = ObjectSchema.create({ description: 'Links a mirrored inbound message to a record it belongs to', displayNameField: 'matched_address', nameField: 'matched_address', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{matched_address}', highlightFields: ['mail_message_id', 'object_name', 'record_id', 'matched_address'], fields: { diff --git a/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts index 98f75078630..4014f6044b2 100644 --- a/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts @@ -37,7 +37,6 @@ export const SysMailMessage = ObjectSchema.create({ description: 'Inbound mail message mirrored from a mailbox', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'from_address', 'received_at', 'association_state'], fields: { diff --git a/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts index ba7dcf3fc5a..e99d3fefc0a 100644 --- a/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts @@ -35,7 +35,6 @@ export const SysMailbox = ObjectSchema.create({ description: 'An inbound mailbox the platform mirrors mail from: its address and its owner', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'address', 'owner_user_id'], fields: { diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 2d130db6405..dbaa7bf6136 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -46,14 +46,14 @@ export const HttpDelivery = ObjectSchema.create({ // written only by this package — lives in the docblock above. description: 'Outgoing HTTP calls made by webhooks and flows, one row per call, with its delivery status.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw delivery UUID. - // `titleFormat` stays for renderers that still read it first; - // `http-delivery-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw delivery UUID. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `http-delivery-display-title.test.ts` holds the formula + // to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label} → {url}', highlightFields: ['source', 'url', 'status', 'attempts', 'next_retry_at'], listViews: { diff --git a/packages/services/service-messaging/src/objects/inbox-message.object.ts b/packages/services/service-messaging/src/objects/inbox-message.object.ts index e34a1814768..81a1dff8192 100644 --- a/packages/services/service-messaging/src/objects/inbox-message.object.ts +++ b/packages/services/service-messaging/src/objects/inbox-message.object.ts @@ -30,7 +30,6 @@ export const InboxMessage = ObjectSchema.create({ }, description: 'User-facing in-app notification rows materialized by the inbox messaging channel.', nameField: 'title', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{title}', highlightFields: ['title', 'user_id', 'severity', 'created_at'], listViews: { diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index b5f8a8ab8c1..1a8f1dee023 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -75,14 +75,13 @@ export const NotificationDelivery = ObjectSchema.create({ // pointer declared, the registry's designate-only pass stamped // `nameField: 'id'` (the first title-eligible field), so a renderer // honouring ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`) drew the raw id as the record page's H1. `titleFormat` - // stays for renderers that still read it first; the formula names the - // recipient as a PERSON where `titleFormat` names the raw recipient, so the - // two no longer read the same text + // `titleFormat`) drew the raw id as the record page's H1. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); the formula names the recipient as a PERSON where that + // template named the raw recipient, so the two no longer read the same text // (`../delivery-recipient-user.integration.test.ts`). displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{channel} → {recipient_id}', highlightFields: ['notification_id', 'recipient_id', 'channel', 'status', 'attempts'], fields: { diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 7810d6d7119..841aed4d151 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -43,15 +43,16 @@ export const NotificationPreference = ObjectSchema.create({ // the docblock above (ADR-0030 Layer 3). description: 'Which topics and channels each user receives; admins set the defaults.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same three columns `titleFormat` names. With no pointer declared, the + // same three columns `titleFormat` named. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first // title-eligible field), so a renderer honouring ADR-0079's order (an // explicit `nameField` wins over `titleFormat`) drew the raw id as the - // record page's H1. `titleFormat` stays for renderers that still read it - // first; `notification-display-title.test.ts` holds the two to the same text. + // record page's H1. The retired `titleFormat` template beside it is gone + // (refused at save and by `os validate`); + // `notification-display-title.test.ts` holds the formula to the text that + // template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} · {topic} · {channel}', highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'], // The one list view this object ships, declared for its `emptyState`: with diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index c9d5f1a930e..62269724875 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -35,17 +35,17 @@ export const NotificationReceipt = ObjectSchema.create({ }, description: 'Per-recipient × channel receipt; the source of truth for notification read-state.', // [ADR-0079] The record title is `state`, the one column `titleFormat` - // names: a single-field title migrates to `nameField` directly, with no + // named: a single-field title migrates to `nameField` directly, with no // formula. With no pointer declared, the registry's designate-only pass // stamped `nameField: 'id'` (the first title-eligible field; a `select` is // never DERIVED), so a renderer honouring ADR-0079's order (an explicit // `nameField` wins over `titleFormat`) drew the raw id as the record page's // H1. An explicit pointer is honoured whatever the field's type (ADR-0079 - // D4). `titleFormat` stays for renderers that still read it first; - // `notification-display-title.test.ts` holds the two to the same text. + // D4). The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `notification-display-title.test.ts` holds + // the formula to the text that template drew. displayNameField: 'state', nameField: 'state', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{state}', highlightFields: ['notification_id', 'user_id', 'channel', 'state', 'at'], fields: { diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 36876b11a7d..5253a4e0fa2 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -68,7 +68,11 @@ export const NotificationTemplate = ObjectSchema.create({ // It names the two channels that read a row and says the inbox reads none // (TEMPLATE_CHANNELS above is the measurement behind both halves). description: 'The email and SMS text each notification topic sends, per language. The in-app inbox never uses these templates.', - titleFormat: '{topic} · {channel} · {locale}', + // [ADR-0079] The record title: the template's subject, the field the + // registry stamped when the retired `titleFormat` template stood here (an + // object definition that declares one is refused at save and by + // `os validate`). + nameField: 'subject', highlightFields: ['topic', 'channel', 'locale', 'is_active'], // The one list view this object ships, declared for its `emptyState`: with diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 8cc70f73c0e..a643eab1fbb 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -32,16 +32,15 @@ export const SysPresence = ObjectSchema.create({ requiredPermissions: ['manage_platform_settings'], description: 'Real-time user presence and activity tracking', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. With no pointer declared, the // registry's designate-only pass stamped `nameField: 'id'` (the first // title-eligible field), so a renderer honouring ADR-0079's order (an // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-presence-display-title.test.ts` pins the pointer and the formula's - // inputs. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-presence-display-title.test.ts` pins the + // pointer and the formula's inputs. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} ({status})', highlightFields: ['user_id', 'status', 'last_seen'], fields: { diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index f7c568838c7..1515210f61f 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -100,7 +100,6 @@ export const SystemFile = ObjectSchema.create({ icon: 'file', description: 'Storage service file metadata (fileId ↔ key mapping)', nameField: 'name', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{name}', highlightFields: ['name', 'mime_type', 'size', 'status', 'created_at'], fields: { diff --git a/packages/services/service-storage/src/objects/system-upload-session.object.ts b/packages/services/service-storage/src/objects/system-upload-session.object.ts index 1450bdb62bf..6e0d0db08a2 100644 --- a/packages/services/service-storage/src/objects/system-upload-session.object.ts +++ b/packages/services/service-storage/src/objects/system-upload-session.object.ts @@ -18,7 +18,6 @@ export const SystemUploadSession = ObjectSchema.create({ icon: 'upload-cloud', description: 'Resumable multipart upload sessions tracked by service-storage', nameField: 'filename', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{filename}', highlightFields: ['filename', 'status', 'uploaded_chunks', 'total_chunks', 'expires_at'], fields: { From 683912589f6c371494c47434dd9139639cad2431 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:10:13 +0000 Subject: [PATCH 03/10] wip: describe sentence, D3 entry, changeset, forced test edits, door and sweep pins Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- .../29007-object-title-format-refused.md | 32 ++++++ content/docs/data-modeling/objects.mdx | 2 +- content/docs/data-modeling/schema-design.mdx | 2 +- content/docs/frontend/building-crud-app.mdx | 2 +- docs/notes/crm-development-standards.mdx | 2 +- .../validate-title-format-retired.test.ts | 102 ++++++++++++++++++ .../src/runtime-gate.object-writes.test.ts | 31 ++++++ .../protocol.draft-publish-verdict.test.ts | 60 +++++++++++ .../identity/identity-display-title.test.ts | 26 ++++- ...anaged-api-method-affordance-sweep.test.ts | 59 +++++++++- ...-approval-delegation-display-title.test.ts | 13 ++- .../src/sys-approval-display-title.test.ts | 26 +++-- ...approval-request-title.integration.test.ts | 9 +- ...-security-assignment-display-title.test.ts | 13 ++- .../sys-automation-run-display-title.test.ts | 13 ++- .../http-delivery-display-title.test.ts | 13 ++- .../notification-display-title.test.ts | 13 ++- .../sys-presence-display-title.test.ts | 13 ++- packages/spec/liveness/sharing_rule.json | 6 +- packages/spec/src/data/object.zod.ts | 2 +- .../18.object-title-format-refused.ts | 34 ++++++ 21 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 .changeset/29007-object-title-format-refused.md create mode 100644 packages/cli/test/validate-title-format-retired.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts diff --git a/.changeset/29007-object-title-format-refused.md b/.changeset/29007-object-title-format-refused.md new file mode 100644 index 00000000000..06ff653a6eb --- /dev/null +++ b/.changeset/29007-object-title-format-refused.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': minor +'@objectstack/plugin-approvals': minor +'@objectstack/plugin-audit': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/plugin-webhooks': minor +'@objectstack/service-automation': minor +'@objectstack/service-mailbox': minor +'@objectstack/service-messaging': minor +'@objectstack/service-realtime': minor +'@objectstack/service-storage': minor +--- + +feat!: an object that declares `titleFormat` is refused at save and by `os validate`, with the `nameField` prescription (#29007) + +Clause-②: no (narrowing) +Metadata-protocol: semantic + + + +**BREAKING** accept-set narrowing on `object.titleFormat`. The key stays in the schema; what changes is which doors accept it. + +- **Before.** `titleFormat` was deprecated (ADR-0079) and still accepted: a save, a publish and `os validate` all passed, with an advisory `title-format-retired` warning on the CLI only. It titled nothing. An explicit `nameField` outranks it, and the registry stamps a derived `nameField` on every object that authors none, so the record page and the list showed one field however the template was written. +- **Now.** `title-format-retired` is an error. An object definition that declares `titleFormat` is refused by `os validate`, `os build` and `os lint`, and at the metadata save gate (`422 INVALID_METADATA`): an active save, a draft promotion and a restore. A draft save still lands, and its `_diagnostics` reads `valid: false` with the refusal its publish gives. The message and the fix line are the prescription the `titleFormat` describe already carried. +- **Unchanged.** A stored object that carries `titleFormat` still parses, loads and is served; no conversion drops the key. It is refused only when it is next saved, published or restored. The title order is unchanged: `nameField`, then its alias `displayNameField`, then the derivation. +- **The platform's own objects.** The 68 system objects that declared `titleFormat` no longer do. 65 already declared the `nameField` that titles them. `sys_attachment`, `sys_audience_binding_suggestion` and `sys_notification_template` now declare the field the registry already stamped (`file_name`, `permission_set_name`, `subject`), so every record title reads as before. +- **`@objectstack/lint`.** `validateTitleFormatRetired` is a new export and a new gating registry entry. `validateRecordTitle` now reports `title-unresolvable` only. + +**The remedy.** Point `nameField` at the title field and drop `titleFormat`. For a composite title that an import, a sort or a search must match, add a `text` field that declares an `expression` (computed and stored on every write) and point `nameField` at it; a `formula` with `returnType: 'text'` stays legal for a title shown for display only. The upgrade guide entry is `object-title-format-refused`. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 240b1d26ff1..f5e43d57fc2 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -67,7 +67,7 @@ export const Account = ObjectSchema.create({ | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | `nameField` | `string` | optional | The stored field used as the record display name, e.g. `'name'` or `'title'` (ADR-0079). The deprecated alias `displayNameField` is still accepted. | -| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence | +| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence. An object definition that declares it is refused at save and by `os validate`; a stored object that carries it still loads | | `imageField` | `string` | optional | The record's picture: names a field of this object whose type is `image` or `avatar` — any other name is refused when the object is validated or published. It is the one object-level declaration the record page header draws beside the title; a record whose field is empty shows no picture. | | `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout` — the old spelling was retired and is now rejected) | | `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) | diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 96e53579e3b..0a09af626c0 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -56,7 +56,7 @@ export const MyObject = ObjectSchema.create({ | `icon` | string | Icon identifier | `'building'` | | `description` | string | Help text | `'Companies...'` | | `nameField` | string | Canonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias `displayNameField` is still accepted | `'name'` | -| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence | `'{{record.name}} - {{record.id}}'` | +| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence. Refused at save and by `os validate`; a stored object that carries it still loads | `'{{record.name}} - {{record.id}}'` | | `highlightFields` | string[] | Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly `compactLayout`; the old spelling was retired and is now rejected) | `['name', 'status']` | ### Enable Features diff --git a/content/docs/frontend/building-crud-app.mdx b/content/docs/frontend/building-crud-app.mdx index 1922dc76381..8502a93d748 100644 --- a/content/docs/frontend/building-crud-app.mdx +++ b/content/docs/frontend/building-crud-app.mdx @@ -123,7 +123,7 @@ export const TaskSchema = { name: 'task', label: 'Task', icon: 'check-circle-2', - titleFormat: '{title}', + nameField: 'title', fields, listViews: { all: { diff --git a/docs/notes/crm-development-standards.mdx b/docs/notes/crm-development-standards.mdx index 54810f9e7ab..481a595fb49 100644 --- a/docs/notes/crm-development-standards.mdx +++ b/docs/notes/crm-development-standards.mdx @@ -94,7 +94,7 @@ export const MyObject = ObjectSchema.create({ description: '...', // Help text // ✅ Display Configuration - titleFormat: '{field1} - {field2}', + nameField: 'field1', // Record title field (ADR-0079); never titleFormat highlightFields: ['field1', 'field2', 'field3'], // ✅ Fields Definition diff --git a/packages/cli/test/validate-title-format-retired.test.ts b/packages/cli/test/validate-title-format-retired.test.ts new file mode 100644 index 00000000000..fd22e29a0fa --- /dev/null +++ b/packages/cli/test/validate-title-format-retired.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29007] `os validate` refuses an object definition that declares the retired + * `titleFormat` — the ruling of record (6574393684, letter B). The verdict is + * the registry's `validateTitleFormatRetired` (`@objectstack/lint`), the same + * entry the metadata save gate runs; what is pinned here is the command: the + * card's reproduction exits 1 with `title-format-retired` and its prescription, + * and the same object pointing `nameField` at the field validates clean. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** The card's object, with either the retired template or the pointer. */ +const stack = (title: string) => ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.acme.titles', name: 'titles', version: '1.0.0', type: 'app' }, + objects: [ + { + name: 'qar8_tf2', + label: 'Title Format', + sharingModel: 'public_read_write', + ${title}, + fields: { + code: { type: 'text', label: 'Code' }, + city: { type: 'text', label: 'City' }, + }, + }, + ], +}, { strict: false }); +`; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runValidate(cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, 'validate', '--json'], + // `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env). + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +const dirs: Record<'templated' | 'pointed', string> = { templated: '', pointed: '' }; + +beforeAll(() => { + const make = (key: keyof typeof dirs, source: string) => { + dirs[key] = mkdtempSync(join(tmpdir(), `os-validate-title-format-${key}-`)); + writeFileSync(join(dirs[key], 'objectstack.config.ts'), source); + linkSpec(dirs[key]); + }; + make('templated', stack(`titleFormat: '{code} @ {city}'`)); + make('pointed', stack(`nameField: 'code'`)); +}); + +afterAll(() => { + for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('#29007 — `os validate` refuses the retired titleFormat', () => { + it('an object declaring titleFormat exits 1 with title-format-retired and its prescription', async () => { + const run = await runValidate(dirs.templated); + expect(run.code, run.stdout + run.stderr).toBe(1); + const payload = JSON.parse(run.stdout) as { valid: boolean; errors: Array> }; + expect(payload.valid).toBe(false); + const refusal = payload.errors.find((e) => JSON.stringify(e).includes('title-format-retired')); + expect(refusal, JSON.stringify(payload.errors)).toBeDefined(); + expect(JSON.stringify(refusal)).toContain('migrate to nameField'); + }, 120_000); + + it('CONTROL — the same object pointing nameField at the field validates clean', async () => { + const run = await runValidate(dirs.pointed); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect((JSON.parse(run.stdout) as { valid: boolean }).valid).toBe(true); + }, 120_000); +}); diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index d6bd0181ecb..05d55b959b5 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -144,6 +144,10 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // leaving the object door is caught by something even though this exact // list cannot move. 'validateReferenceIntegrity', + // [#29007] A ruled join (6574393684, letter B): `titleFormat` is refused + // at the metadata save gate and by `os validate`. Its refusal and the + // re-save of a stored row that carries it are in the cases below. + 'validateTitleFormatRetired', 'lintAutonumberFormats', // A ruled join: the card's acceptance names this door. R13 judges a // roll-up's aggregand against the child objects the snapshot carries @@ -454,6 +458,32 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () ); }); + it('REFUSES an object that declares a titleFormat (validateTitleFormatRetired)', () => { + // [#29007] Ruling of record 6574393684, letter B: the stamped or explicit + // `nameField` outranks the template, so a save that accepted it was a + // silent no-op. The refusal carries the describe's prescription. + const result = expectSingleRefusal(cleanObject({ titleFormat: '{owner} @ {owner}' }), 'title-format-retired'); + const f = result.errors.find((e) => e.rule === 'title-format-retired')!; + expect(f.message).toContain('migrate to nameField'); + expect(result.errors.map((e) => e.rule)).toEqual(['title-format-retired']); + }); + + it('REFUSES the re-save of a stored object that already carries a titleFormat — "Re-saving a row is writing it"', () => { + // [#29007] The stored row keeps parsing and reading; it is refused when it + // is next saved. The differential charges a finding on the written item + // to the write whatever the stored row held. + const stored = [{ ...STORED[0], titleFormat: '{owner}' }]; + const result = runRuntimeAuthoringRules({ + type: 'object', + item: cleanObject({ titleFormat: '{owner}' }), + context: { objects: stored }, + }); + expect(result.errors.map((e) => e.rule)).toEqual(['title-format-retired']); + // …and the same re-save without the key publishes (the control). + const healed = runRuntimeAuthoringRules({ type: 'object', item: cleanObject(), context: { objects: stored } }); + expect(healed.errors, JSON.stringify(healed.errors)).toEqual([]); + }); + // ── What must NOT change ───────────────────────────────────────────── it('publishes a clean object write with every rule having RUN', () => { @@ -467,6 +497,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () 'validateManagedApiMethods', 'validatePresetComparands', 'validateReferenceIntegrity', // [#15254] + 'validateTitleFormatRetired', // [#29007] — see the roster pin above 'lintAutonumberFormats', 'lintSummaryRollups', // see the roster pin above 'validateSecurityPosture', diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index 479c161bf06..0da9e19c2ed 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -283,6 +283,24 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record }, }, }, + { + // [#29007] The card's reproduction: a `titleFormat` on an object that + // authors no `nameField`. The stamped pointer outranks the template, + // so the save was a silent no-op; the ruling of record (6574393684, + // letter B) refuses it at the save gate. + rule: 'title-format-retired', + type: 'object', + item: { + name: 'qar8_tf2', + label: 'Title Format', + sharingModel: 'public_read_write', + titleFormat: '{code} @ {city}', + fields: { + code: { type: 'text', label: 'Code' }, + city: { type: 'text', label: 'City' }, + }, + }, + }, { // Reproduction 5: a dashboard global filter on a field the bound // widget's object does not have. @@ -774,3 +792,45 @@ describe('[#27445] a stored dashboard carrying an undeclared filterBindings key' expect(issue.message).toContain('(declared: region)'); }); }); + +describe('[#29007] a stored object carrying the retired titleFormat', () => { + const titled = CASES.find((c) => c.rule === 'title-format-retired')!.item; + + it('still loads and reads the key — the schema parses it, no conversion drops it', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // A row written before the refusal: the save path with the authoring + // gate switched off stores the body verbatim, as a pre-ruling write did. + process.env.OS_ALLOW_UNLINTED_METADATA_WRITES = '1'; + try { + const saved: any = await (protocol as any).saveMetaItem({ type: 'object', name: titled.name, item: titled, packageId: PKG }); + expect(saved.success).toBe(true); + } finally { + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + } + expect([...rows.values()].filter((r) => r.name === titled.name).map((r) => r.state)).toEqual(['active']); + + const read: any = await protocol.getMetaItem({ type: 'object', name: titled.name, packageId: PKG } as any); + const tf = read.item.titleFormat; + expect(typeof tf === 'string' ? tf : tf?.source).toBe('{code} @ {city}'); + }); + + it('is refused on its next save with the prescription, and the same body without it saves', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const err: any = await (protocol as any) + .saveMetaItem({ type: 'object', name: titled.name, item: titled, packageId: PKG }) + .then(() => null, (e: unknown) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'title-format-retired'); + expect(issue, JSON.stringify(err.issues)).toBeDefined(); + expect(issue.message).toContain('migrate to nameField'); + + // The control: the author's fix — the pointer, no template — saves. + const { titleFormat: _dropped, ...fixed } = titled as Record; + const saved: any = await (protocol as any).saveMetaItem({ + type: 'object', name: titled.name, item: { ...fixed, nameField: 'code' }, packageId: PKG, + }); + expect(saved.success).toBe(true); + }); +}); diff --git a/packages/platform-objects/src/identity/identity-display-title.test.ts b/packages/platform-objects/src/identity/identity-display-title.test.ts index 9a36e3c468a..8f1f68fb614 100644 --- a/packages/platform-objects/src/identity/identity-display-title.test.ts +++ b/packages/platform-objects/src/identity/identity-display-title.test.ts @@ -56,6 +56,26 @@ import { SysTeamMember } from './sys-team-member.object.js'; import { SysTwoFactor } from './sys-two-factor.object.js'; import { SysVerification } from './sys-verification.object.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_account: '{provider_id} - {account_id}', + sys_business_unit_member: '{user_id} in {business_unit_id}', + sys_invitation: 'Invitation for {email}', + sys_member: '{user_id} ({role})', + sys_scim_group_member: '{scim_user_id} in {group_id}', + sys_scim_projection_grant: '{role} → {user_id}', + sys_scim_subject: '{user_id}', + sys_team_member: '{user_id} in {team_id}', + sys_two_factor: 'Two-factor for {user_id}', + sys_verification: 'Verification', +}; + type Row = Record; type Schema = { name: string; nameField?: string; displayNameField?: string; titleFormat?: unknown; fields: Record }; @@ -178,7 +198,7 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o it('renders the `titleFormat` text for a stored row, never the id', () => { const title = evaluateDisplayTitle(c.schema, c.row); expect(title).toBe(c.title); - expect(title).toBe(renderTitleFormat(c.schema.titleFormat, c.row)); + expect(title).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[c.schema.name], c.row)); expect(title).not.toBe(c.row.id); expect(String(title)).not.toContain(String(c.row.id)); }); @@ -196,7 +216,7 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o const row = { id: 'ver_7Rk2', identifier: 'credential-shaped-identifier', value: 'credential-shaped-value' }; const title = String(evaluateDisplayTitle(schema, row)); for (const name of internal) expect(title).not.toContain(String(row[name as 'identifier' | 'value'])); - for (const name of internal) expect(renderTitleFormat(schema.titleFormat, row)).not.toContain(String(row[name as 'identifier' | 'value'])); + for (const name of internal) expect(renderTitleFormat(RETIRED_TITLE_TEMPLATES[schema.name], row)).not.toContain(String(row[name as 'identifier' | 'value'])); for (const name of internal) expect(schema.highlightFields ?? []).not.toContain(name); }); @@ -210,6 +230,6 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o expect(servedPointers(schema)).toEqual({ nameField: 'user_id', displayNameField: 'user_id' }); expect(schema.fields.display_title).toBeUndefined(); const row = { id: 'scs_0Wy2', user_id: 'usr_Ab12', revision: 3 }; - expect(row[resolveDisplayField(schema as any) as 'user_id']).toBe(renderTitleFormat(schema.titleFormat, row)); + expect(row[resolveDisplayField(schema as any) as 'user_id']).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[schema.name], row)); }); }); diff --git a/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts b/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts index e5257dd7dfe..b66d11692eb 100644 --- a/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts +++ b/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts @@ -89,7 +89,12 @@ import { readdirSync, statSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; -import { MANAGED_API_METHOD_UNAFFORDABLE, validateManagedApiMethods } from '@objectstack/lint'; +import { + MANAGED_API_METHOD_UNAFFORDABLE, + TITLE_FORMAT_RETIRED, + validateManagedApiMethods, + validateTitleFormatRetired, +} from '@objectstack/lint'; /** * Seeded from `__dirname`, not from `fileURLToPath(import.meta.url)` as the @@ -352,3 +357,55 @@ describe('#7934 — code-shipped managed objects declare only apiMethods their o expect(findings[0].message).toContain('update'); }); }); + +// ── #29007: the retired `titleFormat`, over the same population ───────────── +// +// The ruling of record on #29007 (6574393684, letter B) refuses `titleFormat` +// on an object definition at the metadata save gate and by `os validate` +// (`validateTitleFormatRetired`, `@objectstack/lint`). This repo's own objects +// are code, registered at boot, and reach neither door — the gap #7934 names +// for the rule above, the same walk closes for this one. An object shipped +// with a template would also be refused the first time a tenant saved it back +// from Studio, so the sweep holds the population at zero. The verdict is the +// lint rule's own; this walk holds no second copy of it. +describe('#29007 — no code-shipped object declares the retired titleFormat', () => { + let loaded: LoadedObject[]; + + beforeAll(async () => { + loaded = await loadObjects(); + }, 120_000); + + const judgeTitles = (objects: readonly LoadedObject[]) => + objects.flatMap((o) => + validateTitleFormatRetired({ objects: [o.def] }).map((f) => ({ file: o.file, object: o.name, rule: f.rule })), + ); + + it('finds none across the repo-wide population', () => { + // Anti-vacuity: the same floor the sweep above asserts, on the same walk. + expect(loaded.length).toBeGreaterThanOrEqual(70); + expect( + judgeTitles(loaded), + 'a code-shipped object declares `titleFormat`, which is refused at save and by `os validate`: ' + + 'set `nameField` to the title field (a composite title is a stored computed field `nameField` ' + + 'points at) and drop the template', + ).toEqual([]); + }); + + it('goes red on an object that declares one, and stays silent on its control', () => { + const declaring: LoadedObject = { + file: 'test/fixture/titled.object.ts', + exportName: 'TitledFixture', + name: 'fixture_titled_object', + def: { name: 'fixture_titled_object', titleFormat: '{name} · {code}', fields: { name: { type: 'text' } } }, + }; + const control: LoadedObject = { + ...declaring, + exportName: 'PointedFixture', + name: 'fixture_pointed_object', + def: { name: 'fixture_pointed_object', nameField: 'name', fields: { name: { type: 'text' } } }, + }; + expect(judgeTitles([...loaded, declaring, control])).toEqual([ + { file: declaring.file, object: declaring.name, rule: TITLE_FORMAT_RETIRED }, + ]); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts index ecc8cad4da6..3d3ce0672ff 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -42,12 +42,23 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_approval_delegation: '{delegator_id} → {delegate_id}', +}; + const SYS = { context: { isSystem: true } } as any; const OBJECT = 'sys_approval_delegation'; /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); return source; diff --git a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts index d02051df63e..7a3d5e5bedd 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts @@ -44,6 +44,19 @@ import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_approval_request: '{process_name}', + sys_approval_action: '{action} · {step_name}', + sys_approval_approver: '{approver} · {request_id}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -129,7 +142,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('Discount Sign-off'); // The pairing, restated: `titleFormat` keeps the machine source, and a // renderer reaches it only where the pointer's value is empty — not here. - expect(renderTitleFormat(SysApprovalRequest.titleFormat, row!)).toBe('flow:deal_desk'); + expect(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalRequest.name], row!)).toBe('flow:deal_desk'); }); it('sys_approval_request: with no flow label stored, the H1 is the template\'s "{process_name}", which display_title matches, never "null" and never the record id', async () => { @@ -143,10 +156,11 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(row).not.toBeNull(); expect(row!.process_label ?? null).toBeNull(); - // The pointer's value is empty, so ADR-0079's order falls through to the - // deprecated template: the machine source, the same text the formula draws. + // The pointer's value is empty, so ADR-0079's order falls through: to the + // retired template once, to the derived `display_title` now — the machine + // source either way, the same text the formula draws. expect(h1Of(SysApprovalRequest, row!) ?? null).toBeNull(); - const fallback = renderTitleFormat(SysApprovalRequest.titleFormat, row!); + const fallback = renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalRequest.name], row!); expect(fallback).toBe('flow:deal_desk'); expect(fallback).not.toContain('opp_42'); expect(fallback).not.toContain('null'); @@ -168,7 +182,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o const row = await engine.findOne('sys_approval_action', { where: { id: stepped.id } }, SYS); expect(h1Of(SysApprovalAction, row!)).toBe('approve · manager_review'); expect(h1Of(SysApprovalAction, row!)).not.toBe(row!.id); - expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(SysApprovalAction.titleFormat, row!)); + expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalAction.name], row!)); expect(resolveRecordTitle(SysApprovalAction, storedOnly(row!))).toBe('approve · manager_review'); const stepless = await engine.insert('sys_approval_action', { @@ -192,7 +206,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(h1Of(SysApprovalApprover, row!)).toBe(`role:finance · ${request.id}`); expect(h1Of(SysApprovalApprover, row!)).not.toBe(row!.id); - expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(SysApprovalApprover.titleFormat, row!)); + expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalApprover.name], row!)); expect(resolveRecordTitle(SysApprovalApprover, storedOnly(row!))).toBe(`role:finance · ${request.id}`); }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts index 7ade70c0214..17c9f133e50 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts @@ -156,7 +156,7 @@ describe('#24665 — the approval request title is the flow label the approvals try { await engine?.destroy(); } catch { /* noop */ } }); - it('the declaration: the title pointer is a stored, declared column, and the template names no record id', async () => { + it('the declaration: the title pointer is a stored, declared column, and no title template is declared', async () => { expect(NAME_FIELD).toBe('process_label'); expect(SysApprovalRequest.displayNameField).toBe(NAME_FIELD); expect((SysApprovalRequest.fields as Record)[NAME_FIELD]).toMatchObject({ type: 'text' }); @@ -165,10 +165,9 @@ describe('#24665 — the approval request title is the flow label the approvals const columns = Object.keys(await driver.getKnex()('sys_approval_request').columnInfo()); expect(columns).toContain(NAME_FIELD); expect(columns).not.toContain('display_title'); - const template = SysApprovalRequest.titleFormat as unknown; - const source = typeof template === 'string' ? template : (template as { source?: string })?.source; - expect(source).toBe('{process_name}'); - expect(source).not.toContain('record_id'); + // The retired `titleFormat` (`{process_name}`) is gone: an object + // definition that declares one is refused at save and by `os validate`. + expect((SysApprovalRequest as { titleFormat?: unknown }).titleFormat).toBeUndefined(); }); it('readable subject: the get serves the flow label under nameField, with no record id and no machine name', async () => { diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index c20b5c0581d..366a9285954 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -51,6 +51,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysUserPosition } from './sys-user-position.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_user_position: '{user_id} → {position}', +}; + const SYS = { context: { isSystem: true } } as any; interface Case { @@ -69,7 +80,7 @@ const CASES: Case[] = [ /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); return source; diff --git a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts index 99cd6b87d41..497396ccdcd 100644 --- a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts +++ b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts @@ -28,6 +28,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysAutomationRun } from './sys-automation-run.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_automation_run: '{flow_name} · {node_id}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -95,7 +106,7 @@ describe('[#20015] sys_automation_run resolves a real record title under ADR-007 expect(h1Of(row!)).toBe('quote_approval · manager_gate'); expect(h1Of(row!)).not.toBe(row!.id); - expect(h1Of(row!)).toBe(renderTitleFormat(SysAutomationRun.titleFormat, row!)); + expect(h1Of(row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysAutomationRun.name], row!)); expect(resolveRecordTitle(SysAutomationRun, storedOnly(row!))).toBe('quote_approval · manager_gate'); }); diff --git a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts index c54fd8aa5a9..6429fe915c4 100644 --- a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts @@ -24,6 +24,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { HttpDelivery } from './http-delivery.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_http_delivery: '{label} → {url}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -99,7 +110,7 @@ describe('[#20015] sys_http_delivery resolves a real record title under ADR-0079 expect(h1Of(row!)).toBe('order.created → https://hooks.example.com/in'); expect(h1Of(row!)).not.toBe(row!.id); - expect(h1Of(row!)).toBe(renderTitleFormat(HttpDelivery.titleFormat, row!)); + expect(h1Of(row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[HttpDelivery.name], row!)); expect(resolveRecordTitle(HttpDelivery, storedOnly(row!))).toBe('order.created → https://hooks.example.com/in'); }); diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index 25256288c5f..96d78a89612 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -61,6 +61,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { NotificationReceipt } from './notification-receipt.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_notification_receipt: '{state}', +}; + const SYS = { context: { isSystem: true } } as any; const NOW = new Date('2026-09-25T00:00:00Z'); @@ -96,7 +107,7 @@ const CASES: Case[] = [ /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); return source; diff --git a/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts index 5ec8c42d0dd..37dfb82eb0e 100644 --- a/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts +++ b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts @@ -33,9 +33,20 @@ import { describe, it, expect } from 'vitest'; import { provisionPrimary, resolveDisplayField } from '@objectstack/spec/data'; import { SysPresence } from './sys-presence.object.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_presence: '{user_id} ({status})', +}; + /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source; if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`); return source; diff --git a/packages/spec/liveness/sharing_rule.json b/packages/spec/liveness/sharing_rule.json index 7b7121698c0..4daebad6c63 100644 --- a/packages/spec/liveness/sharing_rule.json +++ b/packages/spec/liveness/sharing_rule.json @@ -7,13 +7,13 @@ "verifiedAt": "2026-09-17", "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#defineRule — the upsert key: it looks the existing row up by `(name, organization_id)` and writes `name` onto `sys_sharing_rule`, so the authored name decides whether a boot creates a second rule or updates the one already standing; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#getRule resolves the REST by-id-or-name routes against the same column, and every `sys_record_share` row the rule materialises carries `reason: rule:{name}`.", "producer": "packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#translateDeclaredSharingRule — `if (!r?.name || !r?.object) return { skip: { kind: 'unnamed' } }` refuses a declared rule that has no name before anything else runs, and carries the authored value as the served rule's `name` — which packages/plugins/plugin-sharing/src/sharing-rule-service.ts#projectDeclaredRule makes its `id` too, the `source_id` its grants carry; nothing else supplies one.", - "note": "Identity, not display: `titleFormat` is `{label}`, and `name` is the object's `displayNameField`/`nameField` plus the first of its `highlightFields`." + "note": "Identity as well as the record title: `name` is the object's `displayNameField`/`nameField` plus the first of its `highlightFields`." }, "label": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — `titleFormat: '{label}'` makes the authored label the record title of every `sys_sharing_rule` row, and `label` is a column of all four declared listViews (`active`, `inactive`, `by_object`, `all_rules`); ui/packages/components/src/renderers/layout/containers.tsx interpolates `objectSchema.titleFormat` for the rendered title, so the authored string is what an admin reads in Setup.", + "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — `label` is a column of all four declared listViews (`active`, `inactive`, `by_object`, `all_rules`) and the sort key of each, so the authored string is what an admin reads in Setup's rule lists.", "producer": "packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#translateDeclaredSharingRule — `label: String(r.label ?? r.name)` threads the authored label into the served rule, which the sharing-rule service lists as the rule's `label`; without that thread the rule NAME would stand for every declared rule, which is exactly what an unauthored `label` still produces.", "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a \"real\" consumer would live. The read point is the platform object's own record surface, NOT a metadata-admin preview — this type has none (enumerated in the file note above)." }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 67228dde3fe..ffe74cab91d 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3387,7 +3387,7 @@ const ObjectSchemaBase = strictObject( * cross-repo back-compat). New metadata should set `nameField`. */ displayNameField: z.string().optional().describe('[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.'), - titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), + titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), /** * [#21182 — ruling A on objectstack-ai/hotcrm#1199] The record's PICTURE: * names the field whose value the record page header — the record chrome diff --git a/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts new file mode 100644 index 00000000000..1952beb05c9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// `titleFormat` on an object is refused at the metadata save gate and by +// `os validate` (ruling of record 6574393684 on #29007, letter B). Not deleted +// from the schema and not converted: a stored object that carries it still +// parses and reads, and no D2 conversion drops it, because which field a +// composite template should become is the author's choice — a single field, or +// a stored computed field — and the platform does not guess. +export const entry: SemanticMigration = { + id: 'object-title-format-refused', + surface: 'object.titleFormat', + replacement: + 'Point `nameField` at the record\'s title field and drop `titleFormat`. A single-field template ' + + '(`{name}`) becomes `nameField: \'name\'`. A composite template that an import, a sort or a search ' + + 'must match becomes a stored computed field: a `text` field that declares an `expression`, which the ' + + 'engine computes and stores on every write, designated as `nameField`; a `formula` with ' + + '`returnType: \'text\'` stays legal for a title shown for display only. `os validate`, `os build`, ' + + '`os lint` and the metadata save gate (`422 INVALID_METADATA`) refuse the key as `title-format-retired`, ' + + 'an error.', + reason: + 'ADR-0079 retired `titleFormat` in favour of `nameField`: a render-only template the server can neither ' + + 'return nor query. An explicit `nameField` outranks it, and the registry stamps a derived `nameField` ' + + 'on every object that authors none, so a declared template titled nothing while its save and its ' + + 'publish both succeeded. The refusal makes the no-op loud at the doors an author uses. No mechanical ' + + 'move is lossless: a composite template names several fields, and whether its title must be stored ' + + '(matched by an import, sorted, searched) or only shown is not recorded. ADR-0079 / ADR-0087.', + acceptanceCriteria: + 'Load every object (`os validate` or `os build`): no `title-format-retired` finding, and every object ' + + 'that declared a template now declares `nameField`. A stored object row that carries `titleFormat` ' + + 'still loads and is served; its next save, publish or restore is refused until the key is dropped. ' + + 'Done when no authored or stored object carries `titleFormat`.', +}; From 904c42f725901b789605c210acde19f2b32e6a11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:27:15 +0000 Subject: [PATCH 04/10] docs(references): regenerate object reference for the titleFormat refusal sentence (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/system/migration.mdx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index ac8a8ce6ad0..8ca8f3f4b66 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -958,7 +958,7 @@ Metadata query with filtering, sorting, and pagination | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e95571af1cb..e9a0e4377c3 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -162,7 +162,7 @@ const result = ApiMethod.parse(data); | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 988021e4b23..df9e887663d 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -347,7 +347,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | @@ -645,7 +645,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | From 0885931864247341a08465c4a135830e0be74eab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:21:58 +0000 Subject: [PATCH 05/10] docs: author-time rule count 60 -> 61 in CLI transcripts (#29007) The new gating rule validateTitleFormatRetired joins the registry. Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 2a62db5b983..0a73ed21e0f 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 0abaac3bf18..e69eda4f3b6 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -760,7 +760,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 (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 72149b6088c..05d789ed946 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -310,7 +310,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (60)... + → Running author-time rules (61)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index c2b3cb0f278..664b648789c 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (60)... + → Running author-time rules (61)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... From 75c2ac28a8c9c7aaf9648a4f8eb054ad40cc4d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:51:12 +0000 Subject: [PATCH 06/10] docs(references): regenerate from the merged tree (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/references/data/object.mdx | 6 ++++-- content/docs/references/system/migration.mdx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e9a0e4377c3..1f2386d0907 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -226,7 +226,7 @@ const result = ApiMethod.parse(data); | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | -| **searchable** | `boolean` | optional (default: `false`) | Is searchable | +| **searchable** | `boolean` | optional (default: `false`) | Search this field when its object declares no `searchableFields`. The set `search` scans, and the set a `$searchFields` override may name, is then the type-based default (the text-like and select columns) plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column, so it is never scanned either). `false`, the default, does not remove a field the type-based default already scans; to choose the set, declare `searchableFields`. When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | @@ -247,6 +247,7 @@ const result = ApiMethod.parse(data); | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | +| **referenceNameVisible** | `boolean` | optional | [PLANNED — not yet served] Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. Declared ahead of its producer (ADR-0049): no runtime serves the name yet, so setting it changes no read today. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -596,7 +597,7 @@ const result = ApiMethod.parse(data); | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | -| **searchable** | `boolean` | optional (default: `false`) | Is searchable | +| **searchable** | `boolean` | optional (default: `false`) | Search this field when its object declares no `searchableFields`. The set `search` scans, and the set a `$searchFields` override may name, is then the type-based default (the text-like and select columns) plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column, so it is never scanned either). `false`, the default, does not remove a field the type-based default already scans; to choose the set, declare `searchableFields`. When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | @@ -617,6 +618,7 @@ const result = ApiMethod.parse(data); | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | +| **referenceNameVisible** | `boolean` | optional | [PLANNED — not yet served] Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. Declared ahead of its producer (ADR-0049): no runtime serves the name yet, so setting it changes no read today. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index df9e887663d..04cdfc47ad7 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -60,7 +60,7 @@ Add a new field to an existing object | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | -| **searchable** | `boolean` | optional (default: `false`) | Is searchable | +| **searchable** | `boolean` | optional (default: `false`) | Search this field when its object declares no `searchableFields`. The set `search` scans, and the set a `$searchFields` override may name, is then the type-based default (the text-like and select columns) plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column, so it is never scanned either). `false`, the default, does not remove a field the type-based default already scans; to choose the set, declare `searchableFields`. When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | @@ -81,6 +81,7 @@ Add a new field to an existing object | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | +| **referenceNameVisible** | `boolean` | optional | [PLANNED — not yet served] Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. Declared ahead of its producer (ADR-0049): no runtime serves the name yet, so setting it changes no read today. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -493,7 +494,7 @@ Add a new field to an existing object | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | -| **searchable** | `boolean` | optional (default: `false`) | Is searchable | +| **searchable** | `boolean` | optional (default: `false`) | Search this field when its object declares no `searchableFields`. The set `search` scans, and the set a `$searchFields` override may name, is then the type-based default (the text-like and select columns) plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column, so it is never scanned either). `false`, the default, does not remove a field the type-based default already scans; to choose the set, declare `searchableFields`. When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | @@ -514,6 +515,7 @@ Add a new field to an existing object | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | +| **referenceNameVisible** | `boolean` | optional | [PLANNED — not yet served] Declared name exposure (`lookup` and `user` fields only). TRUE declares that a principal who can read the record carrying this field may see the referenced record's display name (the target object's `nameField` value) and id even when that principal cannot read the referenced record. Nothing else of the target: `$expand` on this field returns the same name and id only, the field's `displayField` does not widen it, and the name renders as text with no link to the target. Absent or false keeps the restricted-reference posture for a target the reader cannot read. Declared on the referencing field only; there is no target-object switch. Refused on any other type. Declared ahead of its producer (ADR-0049): no runtime serves the name yet, so setting it changes no read today. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | From 324dfa261479c91d6412aceca4f6096f66612034 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:01:43 +0000 Subject: [PATCH 07/10] chore(changeset): declare Metadata-protocol narrowed for the titleFormat refusal (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- .changeset/29007-object-title-format-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29007-object-title-format-refused.md b/.changeset/29007-object-title-format-refused.md index 06ff653a6eb..d879aeb169f 100644 --- a/.changeset/29007-object-title-format-refused.md +++ b/.changeset/29007-object-title-format-refused.md @@ -17,7 +17,7 @@ feat!: an object that declares `titleFormat` is refused at save and by `os validate`, with the `nameField` prescription (#29007) Clause-②: no (narrowing) -Metadata-protocol: semantic +Metadata-protocol: narrowed From 986011757d558c62c70f162fd458051c51c3fc3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:03:10 +0000 Subject: [PATCH 08/10] docs: author-time rule count 62 in CLI transcripts after merging main (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 0a73ed21e0f..36a75a0198d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (61)... + → Running author-time rules (62)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index e69eda4f3b6..8027e3cc01a 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -760,7 +760,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 (61)... + → Running author-time rules (62)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 05d789ed946..2c5a5cea0e3 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -310,7 +310,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (61)... + → Running author-time rules (62)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 664b648789c..7e923e55f21 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (61)... + → Running author-time rules (62)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... From 90d6dcc00abc2d3ad601f6387596d7625fbd5605 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:33:16 +0000 Subject: [PATCH 09/10] docs(references): regenerate from the merged tree (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/references/data/object.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 1f2386d0907..849cc844d6d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -395,7 +395,7 @@ const result = ApiMethod.parse(data); | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -578,7 +578,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **extend** | `string` | ✅ | Target object name (FQN) to extend | | **fields** | `Record; description?: string; … }>` | optional | Fields to add/override | -| **fieldPatches** | `Record` | optional | Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. | +| **fieldPatches** | `Record` | optional | Display patches of the target object's own fields, keyed by field name (lowercase snake_case, the key grammar of an object's `fields`): change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: `label`, `description`, `inlineHelpText`, `placeholder` on the field, `label` on an option under `options`, keyed by its stored value) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`; a field the same extension replaces in `fields` is refused here. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { technology: { label: 'Tech' } } } }`. | | **label** | `string` | optional | Override label for the extended object | | **pluralLabel** | `string` | optional | Override plural label for the extended object | | **description** | `string` | optional | Override description for the extended object | From 63417d56d5216bbcf5dc0355fbe4915aad2f7dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:42:02 +0000 Subject: [PATCH 10/10] docs: author-time rule count 63 in CLI transcripts after merging main (#29007) Claude-Session: https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 36a75a0198d..dbecc9071ef 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 8027e3cc01a..61ebd4c19bf 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -760,7 +760,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 (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 2c5a5cea0e3..d94b2c3aff6 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -310,7 +310,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (62)... + → Running author-time rules (63)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 7e923e55f21..ccc38f7b4b1 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)...