Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/23829-setup-copy-services.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/plugin-approvals': patch
'@objectstack/plugin-security': patch
'@objectstack/service-automation': patch
'@objectstack/plugin-sharing': patch
---

Clause-②: no

The approval, security, automation and sharing objects describe their records in plain admin
sentences. Approval Delegations now reads "While someone is away, approvals assigned to them
personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per
grant.", User Positions "Which positions each user holds, one row per assignment.", Audience
Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who
signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to
resume, and the recent history of finished runs." Field help on Approval Requests, Approval
Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the
descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and
`mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the
reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those
references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording
only: no field, permission or behaviour changes.
120 changes: 120 additions & 0 deletions packages/plugins/plugin-approvals/src/admin-copy-vocabulary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Admin-facing copy of the approval objects carries no design-record
* vocabulary.
*
* Setup opens three of these objects from its Approvals group (Requests,
* Action History and Delegations); the sentence under each page title is the
* object's `description`, a field's help tooltip is the field's
* `description`, and the generated bundles carry both into every locale. An
* ADR number, a decision id ("D2"), a review-finding id ("finding F2"), an
* API call ("emit()") or a runtime internal ("kernel") belongs in the
* docblock next to the string, never in the string. Every object this package
* ships is judged, not only the three Setup opens: a nav entry added later
* inherits the rule with nothing to update.
*
* The same rule over the other Setup producers lives in each of them
* (`@objectstack/platform-objects`' `src/apps/setup-copy-vocabulary.test.ts`,
* `@objectstack/service-messaging`'s `src/objects/admin-copy-vocabulary.test.ts`);
* the matcher is restated rather than imported, because a shared assertion
* module would be one more export for a test to reach across a package for.
*/

import { describe, expect, it } from 'vitest';
import * as Approvals from './index.js';
// Registered by the plugin, not re-exported by the package entry.
import { SysApprovalToken } from './sys-approval-token.object.js';
import { enObjects } from './translations/en.objects.generated.js';
import { zhCNObjects } from './translations/zh-CN.objects.generated.js';
import { jaJPObjects } from './translations/ja-JP.objects.generated.js';
import { esESObjects } from './translations/es-ES.objects.generated.js';

/** The design-record vocabulary an admin-facing string must not carry. */
const DESIGN_RECORD_VOCABULARY: ReadonlyArray<readonly [string, RegExp]> = [
['an ADR number', /ADR-\d/],
['a decision id', /\bD\d+\b/],
['a review-finding id', /finding F/],
['an API call', /\bemit\(\)/],
['a runtime internal', /\bkernel\b/i],
];

function vocabularyIn(text: string): string[] {
return DESIGN_RECORD_VOCABULARY.filter(([, re]) => re.test(text)).map(([name]) => name);
}

const COPY_KEYS = new Set(['label', 'pluralLabel', 'description']);

function copyStrings(node: unknown, path: string, out: Array<[string, string]>): void {
if (Array.isArray(node)) {
node.forEach((v, i) => copyStrings(v, `${path}[${i}]`, out));
return;
}
if (!node || typeof node !== 'object') return;
for (const [k, v] of Object.entries(node)) {
const at = path ? `${path}.${k}` : k;
if (typeof v === 'string') {
if (COPY_KEYS.has(k)) out.push([at, v]);
} else {
copyStrings(v, at, out);
}
}
}

function leafStrings(node: unknown, path: string, out: Array<[string, string]>): void {
if (typeof node === 'string') {
out.push([path, node]);
return;
}
if (!node || typeof node !== 'object') return;
for (const [k, v] of Object.entries(node)) leafStrings(v, path ? `${path}.${k}` : k, out);
}

function offenders(strings: Array<[string, string]>): string[] {
return strings
.map(([at, text]) => [at, text, vocabularyIn(text)] as const)
.filter(([, , hits]) => hits.length > 0)
.map(([at, text, hits]) => `${at} (${hits.join(', ')}): ${text}`);
}

type ObjectLike = { name: string; fields: Record<string, unknown> };
function isObjectSchema(v: unknown): v is ObjectLike {
return !!v && typeof v === 'object'
&& typeof (v as ObjectLike).name === 'string'
&& !!(v as ObjectLike).fields && typeof (v as ObjectLike).fields === 'object';
}

const OBJECT_BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const;

describe('approval objects: admin-facing copy carries no design-record vocabulary', () => {
it('the matcher recognises each kind of vocabulary it exists to keep out', () => {
expect(vocabularyIn('Suspended run this request gates (ADR-0019).')).toEqual(['an ADR number']);
expect(vocabularyIn('Enforced by the shared predicate, decision D2.')).toEqual(['a decision id']);
expect(vocabularyIn('Bypass rule (finding F2).')).toEqual(['a review-finding id']);
expect(vocabularyIn('One row per emit().')).toEqual(['an API call']);
expect(vocabularyIn('Resumed by a rebuilt kernel.')).toEqual(['a runtime internal']);
expect(vocabularyIn('While someone is away, approvals go to their delegate.')).toEqual([]);
});

it('object, field and view copy of every object this package ships', () => {
const objects: ObjectLike[] = [...(Object.values(Approvals) as unknown[]), SysApprovalToken]
.filter(isObjectSchema);
const names = objects.map((o) => o.name);
for (const name of [
'sys_approval_request', 'sys_approval_action', 'sys_approval_approver',
'sys_approval_token', 'sys_approval_delegation',
]) {
expect(names, `${name} is in the population`).toContain(name);
}
const strings: Array<[string, string]> = [];
for (const def of objects) copyStrings(def, def.name, strings);
expect(offenders(strings)).toEqual([]);
});

it.each(Object.keys(OBJECT_BUNDLES))('every leaf of the %s objects bundle', (locale) => {
const strings: Array<[string, string]> = [];
leafStrings(OBJECT_BUNDLES[locale as keyof typeof OBJECT_BUNDLES], 'objects', strings);
expect(strings.length).toBeGreaterThan(100);
expect(offenders(strings)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ export const SysApprovalDelegation = ObjectSchema.create({
// CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
// needed — RLS/permission sets are the authz.
managedBy: 'system-data',
description:
'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window.',
// The sentence Setup shows under the "Delegations (OOO)" title. It says
// "assigned to them personally" because only individually-routed approvers
// (`user` / `field` / `manager`) are rerouted; team, department, position and
// membership-level approvers are not (`ApprovalService.expandApprovers`).
description: 'While someone is away, approvals assigned to them personally go to their delegate.',
// [ADR-0079] The record title is `display_title`, a text formula over the
// same two columns `titleFormat` names. With no pointer declared, the
// registry's designate-only pass stamped `nameField: 'id'` (the first
Expand Down Expand Up @@ -97,13 +100,14 @@ export const SysApprovalDelegation = ObjectSchema.create({
description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.',
}),

// [ADR-0091 D2] `isGrantActive` is the shared validity predicate.
valid_from: Field.datetime({
label: 'Valid From',
required: false,
group: 'Delegation',
description:
'Rule is inactive before this instant. Null = active immediately. ' +
'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.',
'Enforced at resolution time via isGrantActive — never by a background job.',
}),

valid_until: Field.datetime({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export const SysApprovalRequest = ObjectSchema.create({
required: false,
maxLength: 100,
readonly: true,
description: 'Suspended automation run id this request gates (ADR-0019). The decision resumes it.',
description: 'Suspended automation run id this request gates. The decision resumes it.',
group: 'State',
}),

Expand All @@ -339,15 +339,15 @@ export const SysApprovalRequest = ObjectSchema.create({
required: false,
maxLength: 100,
readonly: true,
description: 'Approval node id within the flow that opened this request (ADR-0019).',
description: 'Approval node id within the flow that opened this request.',
group: 'State',
}),

node_config_json: Field.textarea({
label: 'Node Config',
required: false,
readonly: true,
description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).',
description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests.',
group: 'State',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
flow_run_id: {
label: "Flow Run",
help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
help: "Suspended automation run id this request gates. The decision resumes it."
},
flow_node_id: {
label: "Flow Node",
help: "Approval node id within the flow that opened this request (ADR-0019)."
help: "Approval node id within the flow that opened this request."
},
node_config_json: {
label: "Node Config",
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests."
},
completed_at: {
label: "Completed At"
Expand Down Expand Up @@ -315,7 +315,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
sys_approval_delegation: {
label: "Approval Delegation",
pluralLabel: "Approval Delegations",
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window.",
description: "While someone is away, approvals assigned to them personally go to their delegate.",
fields: {
id: {
label: "Delegation ID"
Expand All @@ -334,7 +334,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
valid_from: {
label: "Valid From",
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive — never by a background job."
},
valid_until: {
label: "Valid Until",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
flow_run_id: {
label: "Flow Run",
help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
help: "Suspended automation run id this request gates. The decision resumes it."
},
flow_node_id: {
label: "Flow Node",
help: "Approval node id within the flow that opened this request (ADR-0019)."
help: "Approval node id within the flow that opened this request."
},
node_config_json: {
label: "Node Config",
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests."
},
completed_at: {
label: "Completado el"
Expand Down Expand Up @@ -315,7 +315,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
sys_approval_delegation: {
label: "Approval Delegation",
pluralLabel: "Approval Delegations",
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window.",
description: "While someone is away, approvals assigned to them personally go to their delegate.",
fields: {
id: {
label: "Delegation ID"
Expand All @@ -334,7 +334,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
valid_from: {
label: "Valid From",
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive — never by a background job."
},
valid_until: {
label: "Valid Until",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
"objects.sys_approval_delegation.description": "517790acf02a372d",
"objects.sys_approval_delegation.description": "177cd798e5944c83",
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
Expand All @@ -36,16 +36,16 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
"objects.sys_approval_delegation.fields.valid_from.help": "20b8f73a1c3976d6",
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
"objects.sys_approval_request.fields.flow_node_id.help": "97e7489e6549d78c",
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
"objects.sys_approval_request.fields.flow_run_id.help": "5718cf2ce8a0abd6",
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
"objects.sys_approval_request.fields.node_config_json.help": "e832e60ef12791e6",
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033",
};
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
flow_run_id: {
label: "Flow Run",
help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
help: "Suspended automation run id this request gates. The decision resumes it."
},
flow_node_id: {
label: "Flow Node",
help: "Approval node id within the flow that opened this request (ADR-0019)."
help: "Approval node id within the flow that opened this request."
},
node_config_json: {
label: "Node Config",
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests."
},
completed_at: {
label: "完了日時"
Expand Down Expand Up @@ -315,7 +315,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
sys_approval_delegation: {
label: "Approval Delegation",
pluralLabel: "Approval Delegations",
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window.",
description: "While someone is away, approvals assigned to them personally go to their delegate.",
fields: {
id: {
label: "Delegation ID"
Expand All @@ -334,7 +334,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
valid_from: {
label: "Valid From",
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive — never by a background job."
},
valid_until: {
label: "Valid Until",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
"objects.sys_approval_delegation.description": "517790acf02a372d",
"objects.sys_approval_delegation.description": "177cd798e5944c83",
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
Expand All @@ -35,16 +35,16 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
"objects.sys_approval_delegation.fields.valid_from.help": "20b8f73a1c3976d6",
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
"objects.sys_approval_request.fields.flow_node_id.help": "97e7489e6549d78c",
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
"objects.sys_approval_request.fields.flow_run_id.help": "5718cf2ce8a0abd6",
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
"objects.sys_approval_request.fields.node_config_json.help": "e832e60ef12791e6",
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033",
};
Loading
Loading