Skip to content

Commit 42ff86f

Browse files
feat(runtime): flow and permission-set clone answers name their notice by a stable noticeCode (#29823)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8NAQdrjsKCddXcRgHBvC8Yg)_ Refs #29727 (leg 1, server. Leg 2, the Console mapping, belongs to the ui lane, so this PR does not close the card.) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:流程与权限集的"新建类似"克隆应答在原有英文 `notice` 旁新增 `noticeCode`;流程固定一个码,权限集按落点三种变体各一个码。 - 为什么改:控制台此前只能原样显示英文句子,中文界面看到英文;有了码,控制台可映射成自己的翻译,未知码仍回退 `notice`。 - 风险与回滚:只加键,`notice` 字节不变(测试逐字钉住);回滚即撤销本 PR,旧控制台忽略新键。 - 先看:`packages/runtime/src/domains/permission-set-clone.ts`(码与句子共用同一落点分类)、`packages/runtime/src/flow-clone.ts`、`packages/runtime/src/domains/automation.ts`(克隆应答那一行)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/domains/permission-set-clone.test.ts`,预期全绿,含三个变体的码与原句逐字比对。 - 需要你做的:无,席位落地。 ## Why Both "New similar" clone answers carried only an English `notice`, which the Console prints verbatim, so a zh-CN screen showed English (#29727, from #29706 row 1). A server sentence reaches the Console translated by code: the Console maps a known code to its own key and keeps the wire text for an unknown one. This PR gives both sentences that code. ## What changes - **Flow clone door** (`POST /api/v1/automation/:name/clone`). The answer carries `data.noticeCode: 'FLOW_CLONE_DISABLED_COPY'` beside the unchanged `FLOW_CLONE_NOTICE`. The code is exported as `FLOW_CLONE_NOTICE_CODE` next to the sentence in `flow-clone.ts`. The key is the optional one `CloneFlowResponseSchema` already declares (#29758 / #29779), so no spec file moves, and the client's `CloneFlowResponse` type already carries it. - **Permission-set clone door** (`POST /api/v1/security/permission-sets/:name/clone`, server-only, no spec schema). `permissionSetCloneNotice` composes three sentences, so there is one code per variant: `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE`, `PERMISSION_SET_CLONE_IN_PACKAGE` and `PERMISSION_SET_CLONE_IN_CREATED_PACKAGE`. No parameter key is needed, because the one value the sentence carries, the package, is already on the answer as `data.package`. A single `permissionSetCloneLandingKind(landing)` sorts a landing into its variant, and both the sentence and `permissionSetCloneNoticeCode(landing)` (through the exported `PERMISSION_SET_CLONE_NOTICE_CODES` table, keyed by that kind) are chosen from that one answer, so code and sentence cannot drift. - **`notice` is unchanged** on both doors, byte for byte, and stays the English fallback. - **Route ledger.** The two clone rows in `route-ledger.ts` now spell their `data` as `{ flow, packageId, notice, noticeCode }` and `{ permissionSet, package, notice, noticeCode }`. - **Docs.** `content/docs/permissions/permission-sets.mdx` names `noticeCode` in the permission-set clone answer, with one clause saying what it is for (claim amendment on #29727, after the docs drift check). Nothing else on that page changed. - **Changeset.** `@objectstack/runtime` patch. ## Tests - `domains/automation-flow-clone.test.ts`: a new case asserts the answer's `noticeCode` is `FLOW_CLONE_DISABLED_COPY`, and as a CONTROL asserts `notice` equals the English sentence spelled out literally (not read off the constant), plus the answer's exact key set. - `domains/automation-flow-clone-custom-package.test.ts` (real protocol, registry and store): the answer carries the code, and `CloneFlowResponseSchema.parse` keeps it. - `domains/permission-set-clone.test.ts`: each of the door's landing cases asserts its code (created pair, reused pair, writable package, and the environment-wide CONTROL, which also keeps its notice). A new table pins every variant's code and its full sentence literally, so a change to `notice` while touching codes reds. - Dogfood over HTTP: `automation-flow-clone-door.dogfood.test.ts` and `permission-set-clone-door.dogfood.test.ts` assert the codes on the served answers (created pair, then reused pair). ## Acceptance notes - The order asked to add the optional field to the Console's type for the permission-set answer if one exists. One does (`ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts`, `PermissionSetCloned`), but `ui/**` is outside this card's claimed file surface, so it is left for leg 2, which reads the code there anyway. - The flow notice's Console type needs nothing: the client's `automation.clone()` returns `CloneFlowResponse['data']`, which already declares `noticeCode`. ## Verification Head `db94ab0b`. All code readings were taken at `ecac38e6`, which has the same tree as `da10eb94` (only the commit message was reworded, to drop a card trailer the pre-push hook refuses). `db94ab0b` adds the one docs line on top of that. For the docs line, `check:doc-authoring`, `check:doc-anchors`, `docs-audit/check-affected-docs`, `check-drift-comment` and `check:nul-bytes` each exit 0 at `db94ab0b`. The lychee lane is CI's. <details><summary>Unit tests: the three door suites 78/78, and the full runtime suite</summary> - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-flow-clone.test.ts src/domains/automation-flow-clone-custom-package.test.ts src/domains/permission-set-clone.test.ts`: `Test Files 3 passed (3)`, `Tests 78 passed (78)`. - `pnpm --filter @objectstack/runtime test`: `Test Files 534 passed (534)`, `Tests 8559 passed | 19 skipped (8578)`. - `pnpm --filter @objectstack/runtime typecheck` and `build`: exit 0. </details> <details><summary>Dogfood over HTTP: both door files 16/16</summary> After `pnpm --filter '@objectstack/dogfood^...' build`: `pnpm --filter @objectstack/dogfood exec vitest run test/automation-flow-clone-door.dogfood.test.ts test/permission-set-clone-door.dogfood.test.ts` gave `Test Files 2 passed (2)`, `Tests 16 passed (16)`. The full dogfood battery was not owed: the title has no `!` and the change has no narrowing arm. </details> <details><summary>Ablation: five mutations through scripts/ablation-replace.mjs, each landed on disk, each red, each restored</summary> The unit suites import the runtime from `src`, so no `dist` step is involved. After each leg `git diff HEAD` was empty. | # | Mutation | Result | |---|---|---| | A1 | flow door drops `noticeCode` (`automation.ts`) | 2 failed / 76 passed: the new unit case and the real-stack answer case | | A2 | permission-set door drops `noticeCode` | 4 failed / 74 passed: created pair, reused pair, writable package, environment-wide CONTROL | | A3 | the code collapses "created" into "in package" | 2 failed / 76 passed: the created-pair door case and its table row | | A4 | CONTROL: the flow sentence drifts by one word | 2 failed / 76 passed: the existing sentence case and the new byte-identity case | | A5 | CONTROL: the permission-set package sentence drifts | 2 failed / 37 passed: the in-package and created-package table rows | </details> <details><summary>Derived gates: 67 derived, 66 run, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 67 families; every one ran and `--ran` reconciled: `67 derived famil(ies) accounted for, 66 run, 1 NOT-MEASURED`. 65 exited 0 on the first run. `check-plugin-teardown-shape.mjs --self-test` exited 3 on the shallow clone, then 0 after `git fetch --unshallow`. `pnpm check:dual-build-cjs-loads` exits 3 because not every package has a `dist` in this tree: NOT MEASURED, left to CI. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change here.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the maintainer brief, which the os-dev order writes in Chinese. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWvgXNaTVoBWSiMCCC)_ <!-- signed: domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d16d64 commit 42ff86f

11 files changed

Lines changed: 168 additions & 14 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
feat(runtime): the flow and permission-set "New similar" answers name their notice by a stable `noticeCode`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Both clone doors answered with an English `notice` and nothing that named it, so a console could only print the sentence verbatim, and a zh-CN screen showed English. Each answer now also carries `noticeCode` beside the unchanged `notice`, so a console can map a code it knows to its own translated sentence and fall back to `notice` for a code it does not know.
11+
12+
- **Flow clone** (`POST /api/v1/automation/:name/clone`): `data.noticeCode` is `FLOW_CLONE_DISABLED_COPY` (exported as `FLOW_CLONE_NOTICE_CODE` beside `FLOW_CLONE_NOTICE`), the key `CloneFlowResponseSchema` already declares.
13+
- **Permission-set clone** (`POST /api/v1/security/permission-sets/:name/clone`): one code per notice variant, `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE`, `PERMISSION_SET_CLONE_IN_PACKAGE` or `PERMISSION_SET_CLONE_IN_CREATED_PACKAGE`. The one value the sentence carries, the package, is already on the answer as `data.package`, so no parameter key is needed. Code and sentence are chosen from the same landing kind (`PERMISSION_SET_CLONE_NOTICE_CODES`, `permissionSetCloneNoticeCode`), so they cannot drift.
14+
- **`notice` is unchanged**, byte for byte, and stays the English fallback.

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,9 @@ the source never reach the copy.
460460
(`packageId`, `protection` and the internal protection envelope),
461461
and two privilege decisions — `adminScope` (delegated administration) and
462462
`isDefault` (the `everyone` suggestion). Grant either deliberately on the copy
463-
if it needs one. The response, `{ permissionSet, package, notice }`, says
464-
where the copy went and what was not copied.
463+
if it needs one. The response, `{ permissionSet, package, notice, noticeCode }`, says
464+
where the copy went and what was not copied. `noticeCode` names the notice's
465+
variant so a console can translate it, and `notice` stays the English fallback.
465466
- **The copy is held by nobody** until you add it to a position or assign it.
466467
- **Who may call it** is who may save a permission set through the metadata
467468
API: a holder of `manage_metadata`. Anyone else is refused `403`

‎packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify';
3838

3939
// Read from the implementation rather than restated: the notice is a contract
4040
// value, and a second spelling of it would agree only until one of them moves.
41-
import { FLOW_CLONE_NOTICE } from '../../../runtime/src/flow-clone.js';
41+
import { FLOW_CLONE_NOTICE, FLOW_CLONE_NOTICE_CODE } from '../../../runtime/src/flow-clone.js';
4242

4343
/** The CRM app's own shipped flow — a real definition, not one this test injects. */
4444
const SOURCE = 'crm_convert_lead_wizard';
@@ -92,10 +92,12 @@ describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher b
9292
expect(res.status, `legal clone: ${text}`).toBe(200);
9393
const body = (await res.json()) as {
9494
success?: boolean;
95-
data?: { flow?: { name?: string; label?: string; status?: string }; packageId?: string; notice?: string };
95+
data?: { flow?: { name?: string; label?: string; status?: string }; packageId?: string; notice?: string; noticeCode?: string };
9696
};
9797
expect(body.success).toBe(true);
9898
expect(body.data?.notice).toBe(FLOW_CLONE_NOTICE);
99+
// [#29727] The notice is named by its code, for a console that translates it.
100+
expect(body.data?.noticeCode).toBe(FLOW_CLONE_NOTICE_CODE);
99101
// [#28149, ADR-0146 D4] "New similar": switched off, in the CRM's paired
100102
// customization package, which the clone ensured on first use.
101103
expect(body.data?.flow).toMatchObject({ name: CLONE, label: CLONE_LABEL, status: 'obsolete' });

‎packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ describe('the permission-set clone door, on a booted showcase', () => {
104104
const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' });
105105
const text = await res.clone().text();
106106
expect(res.status, text).toBe(200);
107-
const data = JSON.parse(text).data as { permissionSet: Record<string, unknown>; package: string | null; notice: string };
107+
const data = JSON.parse(text).data as {
108+
permissionSet: Record<string, unknown>; package: string | null; notice: string; noticeCode?: string;
109+
};
108110
expect(data.permissionSet.name).toBe(DELEGATE_COPY);
109111
expect(data.permissionSet).not.toHaveProperty('adminScope');
110112
expect(data.permissionSet).not.toHaveProperty('packageId');
@@ -113,6 +115,8 @@ describe('the permission-set clone door, on a booted showcase', () => {
113115
// ensure door created for it.
114116
expect(data.package).toBe(SHOWCASE_PAIR);
115117
expect(data.notice).toContain(`'${SHOWCASE_PAIR}'`);
118+
// [#29727] The notice is named by its variant's code: a pair created for this copy.
119+
expect(data.noticeCode).toBe('PERMISSION_SET_CLONE_IN_CREATED_PACKAGE');
116120
const pair = await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`);
117121
expect(pair.status, await pair.clone().text()).toBe(200);
118122

@@ -139,7 +143,10 @@ describe('the permission-set clone door, on a booted showcase', () => {
139143
const res = await clone(token, DEFAULT_SET, { name: DEFAULT_COPY, label: 'My Member Default' });
140144
expect(res.status, await res.clone().text()).toBe(200);
141145
// The second copy REUSES the pair the first created: one pair, the same id.
142-
expect(((await res.json()) as any).data.package).toBe(SHOWCASE_PAIR);
146+
const reused = ((await res.json()) as any).data;
147+
expect(reused.package).toBe(SHOWCASE_PAIR);
148+
// [#29727] Reused, not created: the in-package code.
149+
expect(reused.noticeCode).toBe('PERMISSION_SET_CLONE_IN_PACKAGE');
143150
const listed = ((await (await stack.apiAs(token, 'GET', '/packages')).json()) as any).data;
144151
const pairs = (Array.isArray(listed) ? listed : listed?.packages ?? [])
145152
.filter((p: any) => (p?.manifest?.id ?? p?.id) === SHOWCASE_PAIR);

‎packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { FlowSchema } from '@objectstack/spec/automation';
4646
import { ROUTE_LEDGER } from '../route-ledger.js';
4747
import { HttpDispatcher } from '../http-dispatcher.js';
4848
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
49-
import { FLOW_CLONE_NOTICE, FLOW_CLONE_STATUS } from '../flow-clone.js';
49+
import { FLOW_CLONE_NOTICE, FLOW_CLONE_NOTICE_CODE, FLOW_CLONE_STATUS } from '../flow-clone.js';
5050

5151
let home: string;
5252
let priorHome: string | undefined;
@@ -214,6 +214,9 @@ describe('[#28149] the default target: the copy lands in <base>.custom, switched
214214
expect(row.item.status).toBe('obsolete');
215215
expect(rig.flows.get('acme_lead_followup')?.status).toBe('obsolete');
216216
expect(answer.data.notice).toBe(FLOW_CLONE_NOTICE);
217+
// [#29727] …named by its code, which the declared answer carries.
218+
expect(answer.data.noticeCode).toBe(FLOW_CLONE_NOTICE_CODE);
219+
expect(CloneFlowResponseSchema.parse(answer.body).data.noticeCode).toBe(FLOW_CLONE_NOTICE_CODE);
217220
// …and the original keeps running, untouched.
218221
expect(rig.flows.get(SOURCE)?.status).toBe('active');
219222
expect(await rig.rows(SOURCE)).toEqual([]);

‎packages/runtime/src/domains/automation-flow-clone.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
FLOW_CLONE_MUTATED_FIELDS,
5151
FLOW_CLONE_NAME_TAKEN_STATUS,
5252
FLOW_CLONE_NOTICE,
53+
FLOW_CLONE_NOTICE_CODE,
5354
FLOW_CLONE_STATUS,
5455
} from '../flow-clone.js';
5556

@@ -502,6 +503,27 @@ describe('#12156 — the source, the notice, and the gate', () => {
502503
expect(notice).not.toMatch(/\b(draft|status|off-switch|lifecycle|Studio)\b/i);
503504
});
504505

506+
it('[#29727] names the notice by a stable code beside it, and the notice stays byte-identical', async () => {
507+
// A console shows the notice in its own language by mapping the code;
508+
// `notice` stays the English fallback for a code it does not know.
509+
const source = packagedExemplar();
510+
const { dispatcher } = makeDispatcher([source]);
511+
512+
const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX);
513+
const data = (result.response?.body?.data ?? {}) as Record<string, unknown>;
514+
515+
expect(result.response?.status).toBe(200);
516+
expect(FLOW_CLONE_NOTICE_CODE).toBe('FLOW_CLONE_DISABLED_COPY');
517+
expect(data.noticeCode).toBe(FLOW_CLONE_NOTICE_CODE);
518+
// CONTROL: the English sentence is exactly the one served before the
519+
// code arrived, spelled out here rather than read off the constant.
520+
expect(data.notice).toBe(
521+
'The copy is switched off and does not run until you switch it on, '
522+
+ 'and it still uses the same subflows, actions and objects as the original.',
523+
);
524+
expect(Object.keys(data).sort()).toEqual(['flow', 'notice', 'noticeCode', 'packageId']);
525+
});
526+
505527
it('is admin copy: it names controls, never an endpoint or a request body', async () => {
506528
// The console shows this notice verbatim on the clone result. The REST
507529
// recipe for the same switch is developer copy, in the client SDK.

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
flowCloneNameTakenMessage,
8282
FLOW_CLONE_NAME_TAKEN_STATUS,
8383
FLOW_CLONE_NOTICE,
84+
FLOW_CLONE_NOTICE_CODE,
8485
} from '../flow-clone.js';
8586
// [#28149, ADR-0146 D4] The clone body's declaration (its closed member set)
8687
// and the target rule every "new similar" door shares.
@@ -3964,9 +3965,16 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
39643965
// opens the copy where it lives (Setup's "Open the clone in
39653966
// Studio"): the package is where the copy is, not where it
39663967
// came from, so it records no lineage.
3968+
// [#29727] `noticeCode` names the notice so a console can show
3969+
// it translated; `notice` stays the English fallback.
39673970
return {
39683971
handled: true,
3969-
response: deps.success({ flow: servedFlowDefinition(clone), packageId: written.packageId, notice: FLOW_CLONE_NOTICE }),
3972+
response: deps.success({
3973+
flow: servedFlowDefinition(clone),
3974+
packageId: written.packageId,
3975+
notice: FLOW_CLONE_NOTICE,
3976+
noticeCode: FLOW_CLONE_NOTICE_CODE,
3977+
}),
39703978
};
39713979
}
39723980
}

‎packages/runtime/src/domains/permission-set-clone.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import {
3131
PERMISSION_SET_CLONE_MUTATED_FIELDS,
3232
PERMISSION_SET_CLONE_NOTICE,
3333
clonePermissionSetDefinition,
34+
PERMISSION_SET_CLONE_NOTICE_CODES,
3435
permissionSetCloneNotice,
36+
permissionSetCloneNoticeCode,
3537
permissionSetObjectNames,
3638
} from './permission-set-clone.js';
3739

@@ -197,6 +199,7 @@ describe('the permission-set clone door — success', () => {
197199
permissionSet: save.item,
198200
package: 'crm.custom',
199201
notice: permissionSetCloneNotice({ packageId: 'crm.custom', created: true }),
202+
noticeCode: 'PERMISSION_SET_CLONE_IN_CREATED_PACKAGE',
200203
});
201204
expect(data.notice).toContain("'crm.custom'");
202205
expect(data.notice.endsWith(PERMISSION_SET_CLONE_NOTICE)).toBe(true);
@@ -212,6 +215,8 @@ describe('the permission-set clone door — success', () => {
212215
expect(statusOf(second)).toBe(200);
213216
expect(h.saves.map((s) => s.packageId)).toEqual(['crm.custom', 'crm.custom']);
214217
expect((second as any).response.body.data.notice).toBe(permissionSetCloneNotice({ packageId: 'crm.custom', created: false }));
218+
// [#29727] Reused, not created: the in-package code, not the created one.
219+
expect((second as any).response.body.data.noticeCode).toBe('PERMISSION_SET_CLONE_IN_PACKAGE');
215220
});
216221

217222
it('CONTROL — an environment-authored set copies environment-wide, as before: no package, no ensure', async () => {
@@ -224,6 +229,9 @@ describe('the permission-set clone door — success', () => {
224229
expect(Object.keys(h.saves[0]).sort()).toEqual(['item', 'name', 'parentVersion', 'type']);
225230
expect(h.ensures).toEqual([]);
226231
expect((res as any).response.body.data.package).toBeNull();
232+
// [#29727] The environment-wide variant's code.
233+
expect((res as any).response.body.data.noticeCode).toBe('PERMISSION_SET_CLONE_ENVIRONMENT_WIDE');
234+
expect((res as any).response.body.data.notice).toBe(permissionSetCloneNotice({ packageId: null, created: false }));
227235
});
228236

229237
it('a set already in a writable package copies into that package, with no ensure', async () => {
@@ -235,6 +243,8 @@ describe('the permission-set clone door — success', () => {
235243
expect(h.saves[0].packageId).toBe('com.acme.base');
236244
expect(h.saves[0].item).not.toHaveProperty('_packageId');
237245
expect(h.ensures).toEqual([]);
246+
// [#29727] The in-package variant's code.
247+
expect((res as any).response.body.data.noticeCode).toBe('PERMISSION_SET_CLONE_IN_PACKAGE');
238248
});
239249

240250
it('a given writable target that reaches the set lands there, with no ensure', async () => {
@@ -316,6 +326,45 @@ describe('the permission-set clone door — authority', () => {
316326
});
317327
});
318328

329+
describe('[#29727] the clone notice is named by one stable code per landing', () => {
330+
// CONTROL: the three sentences, spelled out as they were served before the
331+
// codes arrived, so a change to `notice` while adding a code reds here.
332+
const TAIL =
333+
'The copy is a new permission set authored in this system, with no link to the set it was copied '
334+
+ 'from: changes to that set do not reach it. Not copied: delegated-admin scope (`adminScope`) and the '
335+
+ 'everyone-baseline suggestion (`isDefault`) \u2014 grant either deliberately on the copy if it needs one. '
336+
+ 'The copy is held by nobody yet: add it to a position, or assign it to users.';
337+
const VARIANTS = [
338+
{
339+
landing: { packageId: null, created: false },
340+
code: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE',
341+
notice: 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + TAIL,
342+
},
343+
{
344+
landing: { packageId: 'com.acme.base', created: false },
345+
code: 'PERMISSION_SET_CLONE_IN_PACKAGE',
346+
notice: "The copy was saved in package 'com.acme.base', beside the original rather than in place of it, "
347+
+ 'and travels with that package. ' + TAIL,
348+
},
349+
{
350+
landing: { packageId: 'crm.custom', created: true },
351+
code: 'PERMISSION_SET_CLONE_IN_CREATED_PACKAGE',
352+
notice: "The copy was saved in package 'crm.custom', created for it to hold your changes to the package "
353+
+ 'the set came from, beside the original rather than in place of it, and travels with that package. ' + TAIL,
354+
},
355+
] as const;
356+
357+
it.each(VARIANTS)('$code names its sentence, and the sentence is byte-identical', ({ landing, code, notice }) => {
358+
expect(permissionSetCloneNoticeCode(landing)).toBe(code);
359+
expect(permissionSetCloneNotice(landing)).toBe(notice);
360+
});
361+
362+
it('the three codes are distinct and the table holds exactly them', () => {
363+
expect(Object.values(PERMISSION_SET_CLONE_NOTICE_CODES).sort()).toEqual(VARIANTS.map((v) => v.code).sort());
364+
expect(new Set(VARIANTS.map((v) => permissionSetCloneNotice(v.landing))).size).toBe(3);
365+
});
366+
});
367+
319368
describe('the permission-set clone door — refusals', () => {
320369
const refusal = async (opts: BootOptions, source: string, body: unknown, status: number, code: string) => {
321370
const h = boot(opts);

‎packages/runtime/src/domains/permission-set-clone.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
* not a package for every set.
8787
*
8888
* The response names the package (`package`, `null` for environment-wide) and
89-
* the notice says where the copy went ({@link permissionSetCloneNotice}).
89+
* the notice says where the copy went ({@link permissionSetCloneNotice}), named
90+
* by a stable code beside it ({@link permissionSetCloneNoticeCode}).
9091
*
9192
* ## Authority
9293
*
@@ -159,21 +160,53 @@ export interface PermissionSetCloneLanding {
159160
readonly created: boolean;
160161
}
161162

163+
/**
164+
* [#29727] The stable code for each notice variant, served beside the notice
165+
* as `data.noticeCode`. One code per landing, so a console can show the
166+
* sentence in its own language without a parameter key: the one value the
167+
* sentence carries, the package, is already on the answer as `data.package`.
168+
* A console maps a code it knows to its own sentence and falls back to
169+
* `notice` verbatim for a code it does not know; `notice` stays the English
170+
* fallback. Keyed by {@link PermissionSetCloneLandingKind}, the same key
171+
* {@link permissionSetCloneNotice} composes its sentence from, so code and
172+
* sentence cannot drift.
173+
*/
174+
export const PERMISSION_SET_CLONE_NOTICE_CODES = Object.freeze({
175+
environmentWide: 'PERMISSION_SET_CLONE_ENVIRONMENT_WIDE',
176+
inPackage: 'PERMISSION_SET_CLONE_IN_PACKAGE',
177+
inCreatedPackage: 'PERMISSION_SET_CLONE_IN_CREATED_PACKAGE',
178+
} as const);
179+
180+
/** Which of the three notices a landing is told with. */
181+
export type PermissionSetCloneLandingKind = keyof typeof PERMISSION_SET_CLONE_NOTICE_CODES;
182+
183+
/** The one place a landing is sorted into its notice variant. */
184+
export function permissionSetCloneLandingKind(landing: PermissionSetCloneLanding): PermissionSetCloneLandingKind {
185+
if (landing.packageId === null) return 'environmentWide';
186+
return landing.created ? 'inCreatedPackage' : 'inPackage';
187+
}
188+
162189
/**
163190
* The notice for one landing: where the copy went, in the server's words,
164191
* ahead of {@link PERMISSION_SET_CLONE_NOTICE}.
165192
*/
166193
export function permissionSetCloneNotice(landing: PermissionSetCloneLanding): string {
167-
if (landing.packageId === null) {
194+
const kind = permissionSetCloneLandingKind(landing);
195+
if (kind === 'environmentWide') {
168196
return 'The copy was saved environment-wide, in no package, as the set it was copied from is. '
169197
+ PERMISSION_SET_CLONE_NOTICE;
170198
}
171199
return `The copy was saved in package '${landing.packageId}'`
172-
+ (landing.created ? ', created for it to hold your changes to the package the set came from' : '')
200+
+ (kind === 'inCreatedPackage' ? ', created for it to hold your changes to the package the set came from' : '')
173201
+ ', beside the original rather than in place of it, and travels with that package. '
174202
+ PERMISSION_SET_CLONE_NOTICE;
175203
}
176204

205+
/** The code naming {@link permissionSetCloneNotice}'s sentence for the same landing. */
206+
export function permissionSetCloneNoticeCode(landing: PermissionSetCloneLanding): string {
207+
return PERMISSION_SET_CLONE_NOTICE_CODES[permissionSetCloneLandingKind(landing)];
208+
}
209+
177210
/** ADR-0112 status for a target name the catalog already holds. */
178211
export const PERMISSION_SET_CLONE_NAME_TAKEN_STATUS = 409;
179212

@@ -529,6 +562,8 @@ export async function handlePermissionSetCloneWrite(
529562
permissionSet: clone,
530563
package: landing.packageId,
531564
notice: permissionSetCloneNotice(landing),
565+
// [#29727] The notice's code, for a console that translates it.
566+
noticeCode: permissionSetCloneNoticeCode(landing),
532567
}),
533568
};
534569
} catch (err: unknown) {

‎packages/runtime/src/flow-clone.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,18 @@ export const FLOW_CLONE_NOTICE =
207207
'The copy is switched off and does not run until you switch it on, '
208208
+ 'and it still uses the same subflows, actions and objects as the original.';
209209

210+
/**
211+
* The stable code naming {@link FLOW_CLONE_NOTICE}, served beside it as
212+
* `data.noticeCode` (`CloneFlowResponseSchema`, #29758).
213+
*
214+
* [#29727] A console shows the notice in its own language by mapping this code
215+
* to its own translated sentence, and falls back to `notice` verbatim for a
216+
* code it does not know. The code names the SENTENCE, so it changes only when
217+
* the sentence's meaning does; `notice` stays the English fallback, unchanged.
218+
* It lives beside the sentence so the two are edited together.
219+
*/
220+
export const FLOW_CLONE_NOTICE_CODE = 'FLOW_CLONE_DISABLED_COPY' as const;
221+
210222
/** ADR-0112 envelope for the same-name refusal: a status AND a code. */
211223
export const FLOW_CLONE_NAME_TAKEN_STATUS = 409;
212224

0 commit comments

Comments
 (0)