From 999bf8b75388420236022762c61b134b5645d187 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 00:42:13 +0000 Subject: [PATCH] feat(spec): Operation Message Catalog gains refusal-situation keys record_write_denied and approval_recall_not_submitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-side half of the ruled shape-A localization path: two measured hardcoded-English FORBIDDEN refusals (the sharing middleware's by-id write denial, plugin-approvals' non-submitter recall) get situation keys in all four platform locales so their emitters can convert to renderOperationMessage in the consumer cards' own lanes. Keys only — no emitter edits here. Both sentences take no placeholders, re-derived per site per the family rule. record_write_denied is deliberately not record_access_denied restated: the sharing gate fires on rows the read path already admitted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 --- .changeset/operation-message-refusal-keys.md | 36 +++++ .../spec/src/system/operation-message.test.ts | 124 ++++++++++++++++++ packages/spec/src/system/operation-message.ts | 58 +++++++- 3 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 .changeset/operation-message-refusal-keys.md diff --git a/.changeset/operation-message-refusal-keys.md b/.changeset/operation-message-refusal-keys.md new file mode 100644 index 0000000000..7c1ae3a582 --- /dev/null +++ b/.changeset/operation-message-refusal-keys.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): Operation Message Catalog gains two refusal-situation keys — +`record_write_denied` and `approval_recall_not_submitter` (#12493, the +spec-side half of the shape-A ruling on #11993) + +`BUILTIN_OPERATION_MESSAGES` gains two situation keys in all four platform +locales (`en`, `zh-CN`, `ja-JP`, `es-ES`), rendering through +`renderOperationMessage` with the same override/locale/fallback behaviour as +the `record_access_denied` family, addressed for deployment overrides as +`errors.record_write_denied` and `errors.approval_recall_not_submitter`: + +- `record_write_denied` — the user can see this record, but changing or + deleting it is beyond their access (the sharing middleware's by-id write + denial; one key for both write verbs). Deliberately NOT + `record_access_denied` restated: that sentence would be false on a row the + read path already admitted. +- `approval_recall_not_submitter` — the user asked to recall an approval + request someone else submitted; the copy names who CAN act (the submitter, + or an administrator — the #3424 admin override). + +Both sentences take no placeholders, re-derived per site as the family +requires: the only nameable facts at the measured throw sites are object API +names and opaque ids, which must not reach a toast. + +**Operator-visible consequence, stated plainly:** this release ships the +catalog keys only — two refusal situations gain localized copy and a +deployment-override address. The emitters still throw their raw English +strings (`FORBIDDEN: insufficient privileges to …` in the sharing +middleware, `FORBIDDEN: only the submitter may recall this request` in +plugin-approvals) until their consumer halves land separately (#12260 and +#11993). Nothing changes on the wire in this release; once the consumer +halves land, end users see these refusals in their own locale, and any +deployment `translation` defining the two `errors.*` keys takes effect then. diff --git a/packages/spec/src/system/operation-message.test.ts b/packages/spec/src/system/operation-message.test.ts index 4c82529f53..d1f3735e82 100644 --- a/packages/spec/src/system/operation-message.test.ts +++ b/packages/spec/src/system/operation-message.test.ts @@ -294,3 +294,127 @@ describe('operation message catalog — the row-level user copy (#7451)', () => } }); }); + +/** + * #12493 — two keys whose EMITTERS convert in follow-up cards: the sharing + * middleware's by-id write denial (`record_write_denied`, consumer half + * #12260) and plugin-approvals' non-submitter recall refusal + * (`approval_recall_not_submitter`, consumer half #11993). Until those land, + * this catalog block is the only pin the keys have — same battery the #7451 + * family keys get. + */ +describe('operation message catalog — sharing write denial and approvals recall (#12493)', () => { + /** + * The vocabulary a business user must never read in these refusals. It is + * the #7451 list plus the exact nouns the two measured raw strings leaked: + * the wire prefix `FORBIDDEN` and the phrase `insufficient privileges` + * (the sharing site also interpolated the object API name and the row id — + * covered structurally by the placeholder-free case, since the sentences + * take no params at all). + */ + const DEVELOPER_VOCABULARY = [ + 'positions', 'permissionSets', 'permission set', 'capability', + '[Security]', 'Access denied', 'operation', 'row-level', 'CHECK', + 'FORBIDDEN', 'insufficient privileges', + ]; + + const KEYS = ['record_write_denied', 'approval_recall_not_submitter'] as const; + + it('renders the caller locale, not English', () => { + expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'zh-CN' })) + .toBe('您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。'); + expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'en' })) + .toBe('You do not have access to change or delete this record. Contact the person who owns it, or your administrator, if you need to make changes.'); + expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'zh-CN' })) + .toBe('只有提交人可以撤回这条审批请求,如需撤回请联系提交人或管理员。'); + expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'en' })) + .toBe('Only the person who submitted this approval request can recall it. Contact the submitter, or your administrator, if it needs to be recalled.'); + }); + + it('matches a base language against a regional catalog key (ja → ja-JP)', () => { + for (const key of KEYS) { + expect(renderOperationMessage({ messageKey: key }, { locale: 'ja' })) + .toBe(BUILTIN_OPERATION_MESSAGES['ja-JP'][key]); + } + }); + + it('falls back to the en sentence for a locale the catalog does not carry', () => { + // `de-DE` has no catalog entry and no base-language sibling. + for (const key of KEYS) { + expect(renderOperationMessage({ messageKey: key }, { locale: 'de-DE' })) + .toBe(BUILTIN_OPERATION_MESSAGES.en[key]); + } + }); + + it('names no object, no record id and no wire vocabulary — in EVERY locale', () => { + const locales = Object.keys(BUILTIN_OPERATION_MESSAGES); + // Guard the guard: a catalog that lost its locales would make the loop + // below vacuously true, which is exactly the shape of an assertion that + // cannot fail. + expect(locales.length).toBeGreaterThanOrEqual(4); + for (const locale of locales) { + for (const key of KEYS) { + const rendered = renderOperationMessage({ messageKey: key }, { locale }); + // Non-empty and locale-specific, so the absence assertions below cannot + // be satisfied by an empty string. + expect(rendered).toBe(BUILTIN_OPERATION_MESSAGES[locale][key]); + expect(rendered.length).toBeGreaterThan(10); + for (const word of DEVELOPER_VOCABULARY) { + expect(rendered.toLowerCase(), `${locale}.${key} must not say "${word}"`) + .not.toContain(word.toLowerCase()); + } + } + } + }); + + it('says something DIFFERENT from every sibling situation — new keys earn their keep', () => { + // `record_write_denied` exists because `record_access_denied` would be + // FALSE on the sharing gate's rows (the read path already admitted them — + // the user is looking at the record), and `approval_recall_not_submitter` + // exists because naming who CAN act is the refusal's entire content. + // Identical copy would mean the new key is a synonym, which the header + // bars. + const SIBLINGS = ['permission_denied', 'record_access_denied', 'record_change_not_allowed'] as const; + for (const locale of Object.keys(BUILTIN_OPERATION_MESSAGES)) { + for (const key of KEYS) { + for (const sibling of SIBLINGS) { + expect(BUILTIN_OPERATION_MESSAGES[locale][key], `${locale}.${key} vs ${sibling}`) + .not.toBe(BUILTIN_OPERATION_MESSAGES[locale][sibling]); + } + } + expect(BUILTIN_OPERATION_MESSAGES[locale].record_write_denied) + .not.toBe(BUILTIN_OPERATION_MESSAGES[locale].approval_recall_not_submitter); + } + }); + + it('ships no unfilled placeholder in any locale — these sentences take no params', () => { + // Asserts on the CATALOG ENTRY, not on the rendering, and that is the + // difference between a guard and a decoration. Rendering a removed key + // yields the bare messageKey — which has no braces either, so a + // rendering-based version of this case would stay green on a catalog that + // lost the key entirely. + for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) { + for (const key of KEYS) { + expect(catalog[key], `${locale} defines ${key}`).toBeTypeOf('string'); + expect(catalog[key], `${locale}.${key} placeholder-free`).not.toMatch(/[{}]/); + } + } + }); + + it('a deployment translation override wins, under the shared `errors.` address', () => { + for (const key of KEYS) { + expect(operationMessageTranslationKey(key)).toBe(`errors.${key}`); + const translate = (k: string) => (k === `errors.${key}` ? '部署自定义文案。' : k); + expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate })) + .toBe('部署自定义文案。'); + } + }); + + it('a throwing i18n service does not turn a 403 into a 500', () => { + const translate = () => { throw new Error('service down'); }; + for (const key of KEYS) { + expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate })) + .toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'][key]); + } + }); +}); diff --git a/packages/spec/src/system/operation-message.ts b/packages/spec/src/system/operation-message.ts index 553545afc1..2307c9fd58 100644 --- a/packages/spec/src/system/operation-message.ts +++ b/packages/spec/src/system/operation-message.ts @@ -6,13 +6,17 @@ * The localized message templates for the data path's OPERATION-level * refusals — a write the engine declines as a whole, rather than a constraint * one field violated. Members today: the referential-integrity refusal - * (`409 DELETE_RESTRICTED`, `cascadeDeleteRelations`'s `restrict` branch, #7307) - * and three `403 PERMISSION_DENIED` gates in plugin-security — the object CRUD + * (`409 DELETE_RESTRICTED`, `cascadeDeleteRelations`'s `restrict` branch, #7307), + * three `403 PERMISSION_DENIED` gates in plugin-security — the object CRUD * grant and the capability AND-gate (#7414, #7451), the row-level pre-image - * write denial and the row-level CHECK post-image denial (#7451). The catalog - * is the seat for the rest of the family as they are localized — a second - * mechanism for the second producer is exactly what this module exists to - * prevent. + * write denial and the row-level CHECK post-image denial (#7451) — and two + * `403 FORBIDDEN` refusals whose keys land ahead of their emitters (#12493): + * the sharing middleware's by-id write denial (`record_write_denied`; emitter + * conversion is #12260's half) and plugin-approvals' non-submitter recall + * refusal (`approval_recall_not_submitter`; emitter conversion is #11993's + * half). The catalog is the seat for the rest of the family as they are + * localized — a second mechanism for the second producer is exactly what this + * module exists to prevent. * * ## One key per SITUATION, not per wire code (#7451) * @@ -25,6 +29,24 @@ * | `permission_denied` | their permissions do not cover this action, on this object, at all | ask an administrator | * | `record_access_denied` | they may work with this kind of record, but not with THIS one | ask its owner, or an administrator | * | `record_change_not_allowed` | they may edit this record, but not into the state they just asked for | change what they entered | + * | `record_write_denied` | they can see this record, but changing or deleting it is beyond their access | ask its owner, or an administrator | + * | `approval_recall_not_submitter` | they asked to recall an approval request someone else submitted | ask the submitter, or an administrator | + * + * `record_write_denied` (#12493) is NOT `record_access_denied` restated: the + * sharing middleware's by-id write gate fires on a row the READ path already + * admitted — the user is typically looking at the record it refuses — so + * "You do not have access to this record" would be false the moment it + * rendered. The situation is read-yes/write-no, and the honest next step is + * asking the owner for edit access. It is one key for both write verbs + * (update and delete) because the user's situation and remedy are the same; + * which verb was refused is a developer fact that stays on + * `developerMessage` and the structured `details`. + * + * `approval_recall_not_submitter` (#12493) names who CAN act because that is + * the entire content of the refusal: recall belongs to the request's + * submitter (a privileged administrator may also recall to release a stuck + * record — the #3424 override), so the sentence sends the user to the + * submitter or an administrator instead of dead-ending them. * * The distinction the copy does NOT make is the internal one: a caller blocked * by a missing CRUD bit and a caller blocked by a missing `requiredPermissions` @@ -142,6 +164,16 @@ export function operationMessageTranslationKey(messageKey: string): string { * policy predicate, not WHICH field carried the offending value (the * predicate is an authored expression over the whole row). Naming the * object without naming the field would send the user hunting. + * + * The two #12493 keys take no placeholders either, re-derived per site: + * + * - `record_write_denied` — the sharing gate's nameable facts are the + * object's API name and the row's opaque id (the raw string interpolated + * exactly those), which is the #7414 vocabulary that must not reach a + * toast; the user already knows which record they tried to change. + * - `approval_recall_not_submitter` — the throw site knows the submitter + * only as an opaque user id; "the person who submitted this request" is + * the resolvable spelling, and the user's own screen shows who that is. */ export const BUILTIN_OPERATION_MESSAGES: Record> = { en: { @@ -151,6 +183,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record> 'You do not have access to this record. Contact the person who owns it, or your administrator, if you need access.', record_change_not_allowed: 'You are not allowed to save this record with the values you entered. Change them and try again, or contact your administrator if you need access.', + record_write_denied: + 'You do not have access to change or delete this record. Contact the person who owns it, or your administrator, if you need to make changes.', + approval_recall_not_submitter: + 'Only the person who submitted this approval request can recall it. Contact the submitter, or your administrator, if it needs to be recalled.', delete_restricted: 'This {{object}} is still referenced by {{count}} {{dependentObject}} record(s) through “{{field}}”. Delete or reassign them first.', delete_restricted_required: @@ -160,6 +196,8 @@ export const BUILTIN_OPERATION_MESSAGES: Record> permission_denied: '您没有执行此操作的权限,如需访问请联系管理员。', record_access_denied: '您无权访问这条记录,如需访问请联系该记录的负责人或管理员。', record_change_not_allowed: '您无权将这条记录保存为当前填写的内容,请修改后重试,或联系管理员。', + record_write_denied: '您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。', + approval_recall_not_submitter: '只有提交人可以撤回这条审批请求,如需撤回请联系提交人或管理员。', delete_restricted: '该{{object}}正被 {{count}} 条{{dependentObject}}记录通过「{{field}}」引用,请先删除或改派这些记录。', delete_restricted_required: @@ -171,6 +209,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record> 'このレコードにアクセスする権限がありません。アクセスが必要な場合は、レコードの担当者または管理者にお問い合わせください。', record_change_not_allowed: '入力された内容ではこのレコードを保存できません。内容を変更して再試行するか、管理者にお問い合わせください。', + record_write_denied: + 'このレコードを変更または削除する権限がありません。変更が必要な場合は、レコードの担当者または管理者にお問い合わせください。', + approval_recall_not_submitter: + 'この承認申請を取り下げられるのは申請者本人のみです。取り下げが必要な場合は、申請者または管理者にお問い合わせください。', delete_restricted: 'この{{object}}は {{count}} 件の{{dependentObject}}レコードから「{{field}}」で参照されています。先にそれらを削除するか、参照先を変更してください。', delete_restricted_required: @@ -183,6 +225,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record> 'No tiene acceso a este registro. Póngase en contacto con la persona responsable del registro o con su administrador si necesita acceso.', record_change_not_allowed: 'No puede guardar este registro con los valores que ha introducido. Modifíquelos e inténtelo de nuevo o póngase en contacto con su administrador si necesita acceso.', + record_write_denied: + 'No tiene acceso para modificar o eliminar este registro. Póngase en contacto con la persona responsable del registro o con su administrador si necesita hacer cambios.', + approval_recall_not_submitter: + 'Solo la persona que envió esta solicitud de aprobación puede retirarla. Póngase en contacto con el remitente o con su administrador si es necesario retirarla.', delete_restricted: '{{count}} registro(s) de {{dependentObject}} todavía hacen referencia a este {{object}} mediante «{{field}}». Elimínelos o reasígnelos primero.', delete_restricted_required: