From 6552c2096b0c2cfee1d101254656198314ec1520 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:09:24 +0000 Subject: [PATCH 1/8] fix(objectql): a unique refusal's user-facing sentence names the object and field by label Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- .../objectql/src/duplicate-record-error.ts | 41 ++++++++++++++++--- packages/objectql/src/engine.ts | 10 ++--- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/objectql/src/duplicate-record-error.ts b/packages/objectql/src/duplicate-record-error.ts index 3226c7ca0d1..8830e86edfe 100644 --- a/packages/objectql/src/duplicate-record-error.ts +++ b/packages/objectql/src/duplicate-record-error.ts @@ -65,6 +65,14 @@ import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/type * engine's `DELETE_RESTRICTED` refusal splits it: `message` is the sentence * a user-facing surface renders, `developerMessage` is the one addressed to * the application author. + * - **Labels in `message`, API names in `developerMessage`** — every door keeps + * `message` as thrown, so a refused record save and a screen flow show it to + * the end user verbatim. [#24951] It therefore names the object and the field + * by their declared `label` (the engine resolves them from its registry at + * the write), falling back to the API name only where no label is declared — + * the same "object label" naming ruling B of #23556 / #23594 set for the + * approval notifications. The API names stay on `object` / `field` and open + * `developerMessage`, for diagnostics. * * ## ⛔ What this deliberately does NOT do * @@ -79,6 +87,9 @@ export const DUPLICATE_RECORD_CODE = 'DUPLICATE_RECORD' as const; /** The conflict status the engine's sibling 409 refusals declare. */ const DUPLICATE_RECORD_STATUS = 409 as const; +/** The declared labels of the refused write's object and conflicting field. */ +type DuplicateRecordLabels = { readonly object?: string; readonly field?: string }; + export class DuplicateRecordError extends Error { readonly code = DUPLICATE_RECORD_CODE; readonly status = DUPLICATE_RECORD_STATUS; @@ -103,12 +114,16 @@ export class DuplicateRecordError extends Error { cause: unknown, /** The conflicting column, when the dialect determinably named one. */ public readonly field?: string, + /** The declared labels the user-facing `message` names them by; absent ⇒ the API name. */ + labels?: DuplicateRecordLabels, ) { - super(buildDuplicateMessage(object, field)); + super(buildDuplicateMessage(labels?.object || object, field && (labels?.field || field))); this.name = 'DuplicateRecordError'; this.cause = cause; this.developerMessage = - `The driver refused this write as a unique-constraint violation. Its own error is attached ` + + `Duplicate record refused on object '${object}'` + + (field ? `, field '${field}'` : '') + + `. The driver refused this write as a unique-constraint violation. Its own error is attached ` + `as \`cause\`. Branch on the platform code (ADR-0112), never on a dialect's code or message, ` + `so the handling survives a change of store: over the HTTP API this refusal is reported as ` + `\`code === 'UNIQUE_VIOLATION'\` on every route, whole-request and per-row alike; inside the ` + @@ -118,7 +133,8 @@ export class DuplicateRecordError extends Error { } /** - * The user-facing sentence. + * The user-facing sentence, naming the object and the field by label (or by API + * name where none is declared — the caller has already chosen). * * ⛔ It must not begin with a SQL verb. `@objectstack/rest`'s importer runs * every row error through `sanitizeRowError`, whose SQL backstop replaces any @@ -151,8 +167,23 @@ function buildDuplicateMessage(object: string, field?: string): string { * seam that wraps a value another seam already wrapped cannot bury the driver's * error one `cause` step deeper. */ -export function envelopeUniqueViolation(error: unknown, object: string): unknown { +export function envelopeUniqueViolation(error: unknown, object: string, schema?: unknown): unknown { if (error instanceof DuplicateRecordError) return error; if (!isUniqueViolationError(error)) return error; - return new DuplicateRecordError(object, error, uniqueViolationColumn(error)); + const field = uniqueViolationColumn(error); + return new DuplicateRecordError(object, error, field, declaredLabels(schema, field)); +} + +/** + * The declared `label` of the object and of the conflicting field, read off the + * registry's schema for the written object. A label that is absent, empty or not + * a string is simply not returned, so the sentence falls back to the API name. + */ +function declaredLabels(schema: unknown, field: string | undefined): DuplicateRecordLabels { + const s = schema as { label?: unknown; fields?: Record } | undefined; + const text = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined); + return { + object: text(s?.label), + field: field ? text(s?.fields?.[field]?.label) : undefined, + }; } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index dd71823aff7..247b91309a2 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -8224,7 +8224,7 @@ export class ObjectQL implements IObjectQLEngine { // not a unique violation untouched (a deadlock, a NOT NULL, an // unreachable store), so only the recognised conflict changes shape. if (!this.isIssuedAutonumberCollision(error, issued)) { - throw envelopeUniqueViolation(error, object); + throw envelopeUniqueViolation(error, object, this.getSchema(object)); } // Whatever happens next, the stale counter must not survive this call: // leaving it in place is what turned one collision into a storm. @@ -8258,7 +8258,7 @@ export class ObjectQL implements IObjectQLEngine { try { return await driver.create(object, row, driverOptions); } catch (retryError) { - throw envelopeUniqueViolation(retryError, object); + throw envelopeUniqueViolation(retryError, object, this.getSchema(object)); } } } @@ -16589,7 +16589,7 @@ export class ObjectQL implements IObjectQLEngine { object, fields: [...new Set(batchIssued.map((one) => one.field))], }); } - throw envelopeUniqueViolation(error, object); + throw envelopeUniqueViolation(error, object, this.getSchema(object)); } } } else { @@ -18182,7 +18182,7 @@ export class ObjectQL implements IObjectQLEngine { try { result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } catch (driverError) { - throw envelopeUniqueViolation(driverError, object); + throw envelopeUniqueViolation(driverError, object, this.getSchema(object)); } // [#21613] The by-id update returns the driver's post-write // readback (`select *` on driver-sql), so a column no metadata @@ -18523,7 +18523,7 @@ export class ObjectQL implements IObjectQLEngine { try { result = await driver.updateMany!(object, ast, hookContext.input.data as Record, hookContext.input.options as any); } catch (driverError) { - throw envelopeUniqueViolation(driverError, object); + throw envelopeUniqueViolation(driverError, object, this.getSchema(object)); } isPredicateWrite = true; } From 0867c32ddddc7517657c7414b21ba0a83b81f6b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:13:02 +0000 Subject: [PATCH 2/8] test(objectql): pin the label sentence, the API-name fallback, the field-less variant and developerMessage Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- ...ne-duplicate-record-label-sentence.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 packages/objectql/src/engine-duplicate-record-label-sentence.test.ts diff --git a/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts b/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts new file mode 100644 index 00000000000..422eb3bea27 --- /dev/null +++ b/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24951] A unique refusal's user-facing sentence names the object and the + * field by their declared `label`. + * + * Every door keeps `DuplicateRecordError.message` as thrown, so a refused record + * save and a screen flow both show it to the end user verbatim — and it used to + * read `Duplicate record refused on 'crm_account': …`, an API name the user + * cannot act on. The engine now resolves the labels from its registry at the + * write, falling back to the API name only where none is declared; the API + * names stay on `object` / `field` and open `developerMessage`. + * + * Asserted through the real write doors (`insert` single and batch, `update` + * by id and by predicate) on a registry double, so the pin covers the label lookup at each + * call site and not only the sentence builder. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ObjectQL } from './engine'; +import { DuplicateRecordError } from './duplicate-record-error'; +import { SchemaRegistry } from './registry'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +vi.mock('./registry', async () => { + const { createRegistryModuleMock } = await import('./registry-module-mock.js'); + return createRegistryModuleMock(); +}); + +const registryDouble = SchemaRegistry as unknown as { getObject: ReturnType }; + +type Row = Record; + +/** better-sqlite3: names the column, so the envelope carries `field`. */ +const namedColumn = () => + Object.assign( + new Error('insert into `crm_account` (`id`, `name`) values (?, ?) - UNIQUE constraint failed: crm_account.name'), + { code: 'SQLITE_CONSTRAINT_UNIQUE' }, + ); + +/** mysql2: names the INDEX only, so the envelope carries no `field`. */ +const indexOnly = () => + Object.assign(new Error("Duplicate entry 'Acme' for key 'idx_account_name'"), { code: 'ER_DUP_ENTRY', errno: 1062 }); + +const LABELLED = { + name: 'crm_account', + label: 'Account', + fields: { name: { type: 'text', label: 'Account Name' } }, +}; + +const UNLABELLED = { + name: 'crm_account', + fields: { name: { type: 'text' } }, +}; + +function makeRig(schema: unknown, refuse: () => unknown) { + registryDouble.getObject.mockReturnValue(schema); + const driver: any = { + name: 'fake', + version: '0.0.0', + supports: {}, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + checkHealth: vi.fn().mockResolvedValue(true), + execute: vi.fn(), + find: vi.fn(async () => []), + findOne: vi.fn(async () => ({ id: 'r1', name: 'Old' })), + create: vi.fn(async () => { + throw refuse(); + }), + bulkCreate: vi.fn(async () => { + throw refuse(); + }), + update: vi.fn(async () => { + throw refuse(); + }), + updateMany: vi.fn(async () => { + throw refuse(); + }), + delete: vi.fn(), + count: vi.fn(), + }; + const engine = new ObjectQL(); + engine.registerDriver(driver as IDataDriver, true); + return engine; +} + +async function refusalOf(run: () => Promise): Promise { + return run().then( + () => { + throw new Error('expected the write to be refused'); + }, + (e) => e as any, + ); +} + +const DOORS: Array<[string, (engine: ObjectQL) => Promise]> = [ + ['insert', (engine) => engine.insert('crm_account', { name: 'Acme' })], + ['batch insert', (engine) => engine.insert('crm_account', [{ name: 'Acme' }, { name: 'Acme' }])], + ['update by id', (engine) => engine.update('crm_account', { id: 'r1', name: 'Acme' })], + ['update by predicate', (engine) => engine.update('crm_account', { name: 'Acme' }, { where: { name: 'Old' }, multi: true } as any)], +]; + +describe('a unique refusal names the object and field by label (#24951)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe.each(DOORS)('on %s', (_door, write) => { + it('a labelled object and field: the sentence names both labels, and no API name', async () => { + const engine = makeRig(LABELLED, namedColumn); + await engine.init(); + + const failure = await refusalOf(() => write(engine)); + + expect(failure).toBeInstanceOf(DuplicateRecordError); + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.message).toBe( + "Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. " + + 'No record was written.', + ); + expect(failure.message).not.toContain('crm_account'); + // The API names stay where diagnostics read them. + expect(failure.object).toBe('crm_account'); + expect(failure.field).toBe('name'); + }); + + it('an unlabelled object and field: the sentence falls back to the API names', async () => { + const engine = makeRig(UNLABELLED, namedColumn); + await engine.init(); + + const failure = await refusalOf(() => write(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.message).toBe( + "Duplicate record refused on 'crm_account': a unique constraint on 'name' already holds this value. " + + 'No record was written.', + ); + }); + + it('no determinable field: the field-less sentence, naming the object by label', async () => { + const engine = makeRig(LABELLED, indexOnly); + await engine.init(); + + const failure = await refusalOf(() => write(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.field).toBeUndefined(); + expect(failure.message).toBe( + "Duplicate record refused on 'Account': a unique constraint already holds these values. No record was written.", + ); + }); + + it('`developerMessage` names the API names, never the labels', async () => { + const engine = makeRig(LABELLED, namedColumn); + await engine.init(); + + const failure = await refusalOf(() => write(engine)); + + expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account', field 'name'\. /); + expect(failure.developerMessage).not.toContain('Account Name'); + }); + }); + + it('a field-less refusal\'s `developerMessage` names the object API name alone', async () => { + const engine = makeRig(LABELLED, indexOnly); + await engine.init(); + + const failure = await refusalOf(() => engine.insert('crm_account', { name: 'Acme' })); + + expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account'\. /); + }); +}); From 0b4d970945f709b55a0e34958fb8376a86a7bcc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:20:07 +0000 Subject: [PATCH 3/8] test(objectql): drop an unused type alias Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- .../objectql/src/engine-duplicate-record-label-sentence.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts b/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts index 422eb3bea27..83c41d62183 100644 --- a/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts +++ b/packages/objectql/src/engine-duplicate-record-label-sentence.test.ts @@ -29,8 +29,6 @@ vi.mock('./registry', async () => { const registryDouble = SchemaRegistry as unknown as { getObject: ReturnType }; -type Row = Record; - /** better-sqlite3: names the column, so the envelope carries `field`. */ const namedColumn = () => Object.assign( From 6cd0146dfc530d1ca06d34bce95da905b1a963f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:26:43 +0000 Subject: [PATCH 4/8] chore(changeset): objectql patch for the label sentence Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- .changeset/24951-duplicate-message-labels.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/24951-duplicate-message-labels.md diff --git a/.changeset/24951-duplicate-message-labels.md b/.changeset/24951-duplicate-message-labels.md new file mode 100644 index 00000000000..de07fb850f8 --- /dev/null +++ b/.changeset/24951-duplicate-message-labels.md @@ -0,0 +1,14 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a unique refusal's user-facing sentence names the object and the field by label + +Clause-②: no (text only: the user-facing sentence names the object and field by label; no code, status, outcome, developer message or export moves) + +`DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`. + +- **The sentence now uses the declared `label`.** The engine looks up the object's and the field's labels in its registry on every write door that raises the refusal: insert (single and batch), and update by id and by predicate. Example: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` Where no label is declared, the sentence uses the API name, exactly as before. The opening words and `No record was written.` are unchanged. +- **The API names stay available for diagnostics.** They are still on `object` and `field`, and `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. …`. +- **Unchanged:** the code (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. `DuplicateRecordError`'s constructor takes an optional fourth argument with the labels. Callers that pass three arguments get the API-name sentence. +- **For HTTP clients:** on the REST data routes the curated `error` text is unchanged. The wire `developerMessage` (which carries the engine's sentence) now names the labels, and the body's `object` and `field` keys still carry the API names. From debb949fd36e74f0ac1c0d49783e1ee0395a60fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:48:19 +0000 Subject: [PATCH 5/8] test(cli): match the unique refusal's stable opening and its object API name Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- .../meta-stored-legacy-organization.integration.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts index beecf33d29a..e638d64e109 100644 --- a/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts +++ b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts @@ -255,7 +255,9 @@ describe('the ceremony\'s template-promotion save on a v17-shape database', () = const db = caseDb(); const before = (await rawRows(db)).filter((r) => r.type === 'email_template'); const error = await saveTemplateOnCeremonyBoot(db, 'Environment welcome'); - expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/); + // The sentence names the object by label; the API name rides on `object`. + expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/); + expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata'); expect((await rawRows(db)).filter((r) => r.type === 'email_template')).toEqual(before); }, CASE_TIMEOUT_MS); @@ -316,7 +318,9 @@ describe('[ADR-0131 D6/D7] the ceremony boot\'s readers ignore a legacy organiza // organization's lock: the save reached the store, where the re-keyed // unique index refuses an environment row beside the organization's row of // the same key — the refusal the template pin above measures. - expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/); + // The sentence names the object by label; the API name rides on `object`. + expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/); + expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata'); expect(String((error as Error | null)?.message ?? error)).not.toMatch(/lock/i); const org = (await rawRows(db)).find((r) => r.id === 'org_locked_template')!; expect(org.metadata).toEqual(tpl(LOCKED_TEMPLATE, 'Org A locked', { _lock: 'full' })); From fbc46d6c0cc216fde31ea8b032068a2e8d8740c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:48:33 +0000 Subject: [PATCH 6/8] docs: the unique refusal's example names the object by label Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- content/docs/protocol/kernel/error-handling.mdx | 2 +- docs/qa/platform-checklist/areas/records-forms.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 51229fb7f2c..09fdffe38f5 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -265,7 +265,7 @@ class — keeps its own code. (on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the thrown one and can differ from the flat door's `error`: an unregistered object reads `Object 'ghost' not found` here and `Object 'ghost' is not registered` there, and a repeated -unique value reads the engine's `Duplicate record refused on 'account': …` here, the same +unique value reads the engine's `Duplicate record refused on 'Account': …` (the object's label) here, the same sentence `details.developerMessage` carries, where the flat door's `error` reads `A record with this email already exists`. diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index a88c818871d..af0bce07cdc 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4233,7 +4233,7 @@ ], "acceptance": [ { - "clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT ('Duplicate record refused on 'qa_unique': …') and states 'No record was written.', while the user-facing `error` stays generic ('A record with this value already exists'); NEITHER carries a driver prefix, a driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion", + "clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT by its declared label ('Duplicate record refused on 'LABEL': …'), by its API name ('qa_unique') only where the object declares no label, and states 'No record was written.', while the user-facing `error` stays generic ('A record with this value already exists'); NEITHER carries a driver prefix, a driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion", "oracle": "api", "verify": "the refusal body — status, code, and the developerMessage naming the object, read from developerMessage, not from the generic `error` — + a post-refusal count; the SQL-family envelope is single-sourced at packages/objectql/src/duplicate-record-error.ts (buildDuplicateMessage / envelopeUniqueViolation); the memory driver's `object.field` sentence (memory-unique-constraint.ts uniqueViolationError) is asserted by memory-unique-constraint.test.ts, not live", "evidence": "refusal response + before/after counts" From 49bf2c2d1d39b78996e21e0b6f57bd438895f668 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:01:40 +0000 Subject: [PATCH 7/8] =?UTF-8?q?chore(changeset):=20re-declare=20the=20Clau?= =?UTF-8?q?se-=E2=91=A1=20line=20as=20the=20seat=20restated=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- .changeset/24951-duplicate-message-labels.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24951-duplicate-message-labels.md b/.changeset/24951-duplicate-message-labels.md index de07fb850f8..e5e88935581 100644 --- a/.changeset/24951-duplicate-message-labels.md +++ b/.changeset/24951-duplicate-message-labels.md @@ -4,7 +4,7 @@ fix(objectql): a unique refusal's user-facing sentence names the object and the field by label -Clause-②: no (text only: the user-facing sentence names the object and field by label; no code, status, outcome, developer message or export moves) +Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves) `DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`. From 2e76ecfc31ce49425c9f09f6ee39b73386a474d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:43:26 +0000 Subject: [PATCH 8/8] docs(qa): bump records-forms.field-unique-enforcement to revision 9 for the label clause Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo --- docs/qa/platform-checklist/areas/records-forms.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 87d8613f44e..63e6fe0f176 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4267,7 +4267,7 @@ "title": "Field-level `unique` on the write path: colliding writes refused 409 UNIQUE_VIOLATION on both driver families, NULL-distinct, organization-scoped by default, whole-batch refusal, autonumber resync converges", "since": "v17", "status": "active", - "revision": 8, + "revision": 9, "priority": "P1", "surface": "api", "personas": [ @@ -4401,6 +4401,12 @@ "date": "2026-10-21", "change": "Respelled — fixtures.requires[0]: 'an isolated dev boot on the stock SQLite store' → 'a dev boot on its own port and DB, on the stock SQLite store'. PR #25003's out-of-scope finding (round 17 of wave anchor #24579; ACCEPT 6304389370, landing record 6304641541): 22 non-history texts in 17 items named the own-port boot or its DB with 'isolated' in other words, the phrasing round 12 found a runner reading as the `isolated` tenancy posture (OS_TENANCY_POSTURE=isolated). The new spelling names the boot or the DB by what it is, the run's own port and DB (RUNNER.md's trap-table counter: own port/DB, skill §0), and names no posture. Read at base 93e6c1a5f8. No clause changed", "ref": "#25018" + }, + { + "revision": 9, + "date": "2026-10-21", + "change": "acceptance[0].clause: the developerMessage now NAMES THE OBJECT by its declared label ('Duplicate record refused on 'LABEL': …'), by its API name ('qa_unique') only where the object declares no label, where it named the object by its API name ('Duplicate record refused on 'qa_unique': …'). The engine's unique-refusal sentence (packages/objectql/src/duplicate-record-error.ts) names the object and field by label since #24951; the REST data route carries that sentence on developerMessage. Status 409, code UNIQUE_VIOLATION, the generic user-facing `error` and 'No record was written.' are unchanged", + "ref": "#25021" } ] },