Skip to content

Commit 999bf8b

Browse files
committed
feat(spec): Operation Message Catalog gains refusal-situation keys record_write_denied and approval_recall_not_submitter
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4
1 parent 527e050 commit 999bf8b

3 files changed

Lines changed: 212 additions & 6 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): Operation Message Catalog gains two refusal-situation keys —
6+
`record_write_denied` and `approval_recall_not_submitter` (#12493, the
7+
spec-side half of the shape-A ruling on #11993)
8+
9+
`BUILTIN_OPERATION_MESSAGES` gains two situation keys in all four platform
10+
locales (`en`, `zh-CN`, `ja-JP`, `es-ES`), rendering through
11+
`renderOperationMessage` with the same override/locale/fallback behaviour as
12+
the `record_access_denied` family, addressed for deployment overrides as
13+
`errors.record_write_denied` and `errors.approval_recall_not_submitter`:
14+
15+
- `record_write_denied` — the user can see this record, but changing or
16+
deleting it is beyond their access (the sharing middleware's by-id write
17+
denial; one key for both write verbs). Deliberately NOT
18+
`record_access_denied` restated: that sentence would be false on a row the
19+
read path already admitted.
20+
- `approval_recall_not_submitter` — the user asked to recall an approval
21+
request someone else submitted; the copy names who CAN act (the submitter,
22+
or an administrator — the #3424 admin override).
23+
24+
Both sentences take no placeholders, re-derived per site as the family
25+
requires: the only nameable facts at the measured throw sites are object API
26+
names and opaque ids, which must not reach a toast.
27+
28+
**Operator-visible consequence, stated plainly:** this release ships the
29+
catalog keys only — two refusal situations gain localized copy and a
30+
deployment-override address. The emitters still throw their raw English
31+
strings (`FORBIDDEN: insufficient privileges to …` in the sharing
32+
middleware, `FORBIDDEN: only the submitter may recall this request` in
33+
plugin-approvals) until their consumer halves land separately (#12260 and
34+
#11993). Nothing changes on the wire in this release; once the consumer
35+
halves land, end users see these refusals in their own locale, and any
36+
deployment `translation` defining the two `errors.*` keys takes effect then.

‎packages/spec/src/system/operation-message.test.ts‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,127 @@ describe('operation message catalog — the row-level user copy (#7451)', () =>
294294
}
295295
});
296296
});
297+
298+
/**
299+
* #12493 — two keys whose EMITTERS convert in follow-up cards: the sharing
300+
* middleware's by-id write denial (`record_write_denied`, consumer half
301+
* #12260) and plugin-approvals' non-submitter recall refusal
302+
* (`approval_recall_not_submitter`, consumer half #11993). Until those land,
303+
* this catalog block is the only pin the keys have — same battery the #7451
304+
* family keys get.
305+
*/
306+
describe('operation message catalog — sharing write denial and approvals recall (#12493)', () => {
307+
/**
308+
* The vocabulary a business user must never read in these refusals. It is
309+
* the #7451 list plus the exact nouns the two measured raw strings leaked:
310+
* the wire prefix `FORBIDDEN` and the phrase `insufficient privileges`
311+
* (the sharing site also interpolated the object API name and the row id —
312+
* covered structurally by the placeholder-free case, since the sentences
313+
* take no params at all).
314+
*/
315+
const DEVELOPER_VOCABULARY = [
316+
'positions', 'permissionSets', 'permission set', 'capability',
317+
'[Security]', 'Access denied', 'operation', 'row-level', 'CHECK',
318+
'FORBIDDEN', 'insufficient privileges',
319+
];
320+
321+
const KEYS = ['record_write_denied', 'approval_recall_not_submitter'] as const;
322+
323+
it('renders the caller locale, not English', () => {
324+
expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'zh-CN' }))
325+
.toBe('您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。');
326+
expect(renderOperationMessage({ messageKey: 'record_write_denied' }, { locale: 'en' }))
327+
.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.');
328+
expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'zh-CN' }))
329+
.toBe('只有提交人可以撤回这条审批请求,如需撤回请联系提交人或管理员。');
330+
expect(renderOperationMessage({ messageKey: 'approval_recall_not_submitter' }, { locale: 'en' }))
331+
.toBe('Only the person who submitted this approval request can recall it. Contact the submitter, or your administrator, if it needs to be recalled.');
332+
});
333+
334+
it('matches a base language against a regional catalog key (ja → ja-JP)', () => {
335+
for (const key of KEYS) {
336+
expect(renderOperationMessage({ messageKey: key }, { locale: 'ja' }))
337+
.toBe(BUILTIN_OPERATION_MESSAGES['ja-JP'][key]);
338+
}
339+
});
340+
341+
it('falls back to the en sentence for a locale the catalog does not carry', () => {
342+
// `de-DE` has no catalog entry and no base-language sibling.
343+
for (const key of KEYS) {
344+
expect(renderOperationMessage({ messageKey: key }, { locale: 'de-DE' }))
345+
.toBe(BUILTIN_OPERATION_MESSAGES.en[key]);
346+
}
347+
});
348+
349+
it('names no object, no record id and no wire vocabulary — in EVERY locale', () => {
350+
const locales = Object.keys(BUILTIN_OPERATION_MESSAGES);
351+
// Guard the guard: a catalog that lost its locales would make the loop
352+
// below vacuously true, which is exactly the shape of an assertion that
353+
// cannot fail.
354+
expect(locales.length).toBeGreaterThanOrEqual(4);
355+
for (const locale of locales) {
356+
for (const key of KEYS) {
357+
const rendered = renderOperationMessage({ messageKey: key }, { locale });
358+
// Non-empty and locale-specific, so the absence assertions below cannot
359+
// be satisfied by an empty string.
360+
expect(rendered).toBe(BUILTIN_OPERATION_MESSAGES[locale][key]);
361+
expect(rendered.length).toBeGreaterThan(10);
362+
for (const word of DEVELOPER_VOCABULARY) {
363+
expect(rendered.toLowerCase(), `${locale}.${key} must not say "${word}"`)
364+
.not.toContain(word.toLowerCase());
365+
}
366+
}
367+
}
368+
});
369+
370+
it('says something DIFFERENT from every sibling situation — new keys earn their keep', () => {
371+
// `record_write_denied` exists because `record_access_denied` would be
372+
// FALSE on the sharing gate's rows (the read path already admitted them —
373+
// the user is looking at the record), and `approval_recall_not_submitter`
374+
// exists because naming who CAN act is the refusal's entire content.
375+
// Identical copy would mean the new key is a synonym, which the header
376+
// bars.
377+
const SIBLINGS = ['permission_denied', 'record_access_denied', 'record_change_not_allowed'] as const;
378+
for (const locale of Object.keys(BUILTIN_OPERATION_MESSAGES)) {
379+
for (const key of KEYS) {
380+
for (const sibling of SIBLINGS) {
381+
expect(BUILTIN_OPERATION_MESSAGES[locale][key], `${locale}.${key} vs ${sibling}`)
382+
.not.toBe(BUILTIN_OPERATION_MESSAGES[locale][sibling]);
383+
}
384+
}
385+
expect(BUILTIN_OPERATION_MESSAGES[locale].record_write_denied)
386+
.not.toBe(BUILTIN_OPERATION_MESSAGES[locale].approval_recall_not_submitter);
387+
}
388+
});
389+
390+
it('ships no unfilled placeholder in any locale — these sentences take no params', () => {
391+
// Asserts on the CATALOG ENTRY, not on the rendering, and that is the
392+
// difference between a guard and a decoration. Rendering a removed key
393+
// yields the bare messageKey — which has no braces either, so a
394+
// rendering-based version of this case would stay green on a catalog that
395+
// lost the key entirely.
396+
for (const [locale, catalog] of Object.entries(BUILTIN_OPERATION_MESSAGES)) {
397+
for (const key of KEYS) {
398+
expect(catalog[key], `${locale} defines ${key}`).toBeTypeOf('string');
399+
expect(catalog[key], `${locale}.${key} placeholder-free`).not.toMatch(/[{}]/);
400+
}
401+
}
402+
});
403+
404+
it('a deployment translation override wins, under the shared `errors.` address', () => {
405+
for (const key of KEYS) {
406+
expect(operationMessageTranslationKey(key)).toBe(`errors.${key}`);
407+
const translate = (k: string) => (k === `errors.${key}` ? '部署自定义文案。' : k);
408+
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
409+
.toBe('部署自定义文案。');
410+
}
411+
});
412+
413+
it('a throwing i18n service does not turn a 403 into a 500', () => {
414+
const translate = () => { throw new Error('service down'); };
415+
for (const key of KEYS) {
416+
expect(renderOperationMessage({ messageKey: key }, { locale: 'zh-CN', translate }))
417+
.toBe(BUILTIN_OPERATION_MESSAGES['zh-CN'][key]);
418+
}
419+
});
420+
});

‎packages/spec/src/system/operation-message.ts‎

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
* The localized message templates for the data path's OPERATION-level
77
* refusals — a write the engine declines as a whole, rather than a constraint
88
* one field violated. Members today: the referential-integrity refusal
9-
* (`409 DELETE_RESTRICTED`, `cascadeDeleteRelations`'s `restrict` branch, #7307)
10-
* and three `403 PERMISSION_DENIED` gates in plugin-security — the object CRUD
9+
* (`409 DELETE_RESTRICTED`, `cascadeDeleteRelations`'s `restrict` branch, #7307),
10+
* three `403 PERMISSION_DENIED` gates in plugin-security — the object CRUD
1111
* grant and the capability AND-gate (#7414, #7451), the row-level pre-image
12-
* write denial and the row-level CHECK post-image denial (#7451). The catalog
13-
* is the seat for the rest of the family as they are localized — a second
14-
* mechanism for the second producer is exactly what this module exists to
15-
* prevent.
12+
* write denial and the row-level CHECK post-image denial (#7451) — and two
13+
* `403 FORBIDDEN` refusals whose keys land ahead of their emitters (#12493):
14+
* the sharing middleware's by-id write denial (`record_write_denied`; emitter
15+
* conversion is #12260's half) and plugin-approvals' non-submitter recall
16+
* refusal (`approval_recall_not_submitter`; emitter conversion is #11993's
17+
* half). The catalog is the seat for the rest of the family as they are
18+
* localized — a second mechanism for the second producer is exactly what this
19+
* module exists to prevent.
1620
*
1721
* ## One key per SITUATION, not per wire code (#7451)
1822
*
@@ -25,6 +29,24 @@
2529
* | `permission_denied` | their permissions do not cover this action, on this object, at all | ask an administrator |
2630
* | `record_access_denied` | they may work with this kind of record, but not with THIS one | ask its owner, or an administrator |
2731
* | `record_change_not_allowed` | they may edit this record, but not into the state they just asked for | change what they entered |
32+
* | `record_write_denied` | they can see this record, but changing or deleting it is beyond their access | ask its owner, or an administrator |
33+
* | `approval_recall_not_submitter` | they asked to recall an approval request someone else submitted | ask the submitter, or an administrator |
34+
*
35+
* `record_write_denied` (#12493) is NOT `record_access_denied` restated: the
36+
* sharing middleware's by-id write gate fires on a row the READ path already
37+
* admitted — the user is typically looking at the record it refuses — so
38+
* "You do not have access to this record" would be false the moment it
39+
* rendered. The situation is read-yes/write-no, and the honest next step is
40+
* asking the owner for edit access. It is one key for both write verbs
41+
* (update and delete) because the user's situation and remedy are the same;
42+
* which verb was refused is a developer fact that stays on
43+
* `developerMessage` and the structured `details`.
44+
*
45+
* `approval_recall_not_submitter` (#12493) names who CAN act because that is
46+
* the entire content of the refusal: recall belongs to the request's
47+
* submitter (a privileged administrator may also recall to release a stuck
48+
* record — the #3424 override), so the sentence sends the user to the
49+
* submitter or an administrator instead of dead-ending them.
2850
*
2951
* The distinction the copy does NOT make is the internal one: a caller blocked
3052
* by a missing CRUD bit and a caller blocked by a missing `requiredPermissions`
@@ -142,6 +164,16 @@ export function operationMessageTranslationKey(messageKey: string): string {
142164
* policy predicate, not WHICH field carried the offending value (the
143165
* predicate is an authored expression over the whole row). Naming the
144166
* object without naming the field would send the user hunting.
167+
*
168+
* The two #12493 keys take no placeholders either, re-derived per site:
169+
*
170+
* - `record_write_denied` — the sharing gate's nameable facts are the
171+
* object's API name and the row's opaque id (the raw string interpolated
172+
* exactly those), which is the #7414 vocabulary that must not reach a
173+
* toast; the user already knows which record they tried to change.
174+
* - `approval_recall_not_submitter` — the throw site knows the submitter
175+
* only as an opaque user id; "the person who submitted this request" is
176+
* the resolvable spelling, and the user's own screen shows who that is.
145177
*/
146178
export const BUILTIN_OPERATION_MESSAGES: Record<string, Record<string, string>> = {
147179
en: {
@@ -151,6 +183,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record<string, Record<string, string>>
151183
'You do not have access to this record. Contact the person who owns it, or your administrator, if you need access.',
152184
record_change_not_allowed:
153185
'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.',
186+
record_write_denied:
187+
'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.',
188+
approval_recall_not_submitter:
189+
'Only the person who submitted this approval request can recall it. Contact the submitter, or your administrator, if it needs to be recalled.',
154190
delete_restricted:
155191
'This {{object}} is still referenced by {{count}} {{dependentObject}} record(s) through “{{field}}”. Delete or reassign them first.',
156192
delete_restricted_required:
@@ -160,6 +196,8 @@ export const BUILTIN_OPERATION_MESSAGES: Record<string, Record<string, string>>
160196
permission_denied: '您没有执行此操作的权限,如需访问请联系管理员。',
161197
record_access_denied: '您无权访问这条记录,如需访问请联系该记录的负责人或管理员。',
162198
record_change_not_allowed: '您无权将这条记录保存为当前填写的内容,请修改后重试,或联系管理员。',
199+
record_write_denied: '您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。',
200+
approval_recall_not_submitter: '只有提交人可以撤回这条审批请求,如需撤回请联系提交人或管理员。',
163201
delete_restricted:
164202
'该{{object}}正被 {{count}} 条{{dependentObject}}记录通过「{{field}}」引用,请先删除或改派这些记录。',
165203
delete_restricted_required:
@@ -171,6 +209,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record<string, Record<string, string>>
171209
'このレコードにアクセスする権限がありません。アクセスが必要な場合は、レコードの担当者または管理者にお問い合わせください。',
172210
record_change_not_allowed:
173211
'入力された内容ではこのレコードを保存できません。内容を変更して再試行するか、管理者にお問い合わせください。',
212+
record_write_denied:
213+
'このレコードを変更または削除する権限がありません。変更が必要な場合は、レコードの担当者または管理者にお問い合わせください。',
214+
approval_recall_not_submitter:
215+
'この承認申請を取り下げられるのは申請者本人のみです。取り下げが必要な場合は、申請者または管理者にお問い合わせください。',
174216
delete_restricted:
175217
'この{{object}}は {{count}} 件の{{dependentObject}}レコードから「{{field}}」で参照されています。先にそれらを削除するか、参照先を変更してください。',
176218
delete_restricted_required:
@@ -183,6 +225,10 @@ export const BUILTIN_OPERATION_MESSAGES: Record<string, Record<string, string>>
183225
'No tiene acceso a este registro. Póngase en contacto con la persona responsable del registro o con su administrador si necesita acceso.',
184226
record_change_not_allowed:
185227
'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.',
228+
record_write_denied:
229+
'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.',
230+
approval_recall_not_submitter:
231+
'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.',
186232
delete_restricted:
187233
'{{count}} registro(s) de {{dependentObject}} todavía hacen referencia a este {{object}} mediante «{{field}}». Elimínelos o reasígnelos primero.',
188234
delete_restricted_required:

0 commit comments

Comments
 (0)