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
18 changes: 18 additions & 0 deletions .changeset/23802-setup-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/platform-objects': patch
'@objectstack/metadata-core': patch
'@objectstack/service-messaging': patch
---

Clause-②: no

Setup pages describe their records in plain admin sentences. Business Units now reads "Your
company structure — divisions, departments, regions and offices — used to scope data and
reports.", Notification Events "Every notification the platform sent, one row per event.",
Notification Preferences "Which topics and channels each user receives; admins set the
defaults.", Notification Templates "The subject and body each notification topic sends, per
channel and language." and HTTP Deliveries "Outgoing HTTP calls made by webhooks and flows, one
row per call, with its delivery status." Field help on Users, Organizations, Invitations and SSO
Providers, and the metadata-storage and notification-delivery object descriptions, no longer
carry design-record numbers; 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.
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export const SysMetadataAuditObject = ObjectSchema.create({
retention: { maxAge: '365d' },
archive: { after: '365d', to: 'archive' },
},
description: 'Append-only audit trail of metadata write decisions (ADR-0010).',
// ADR-0010 — the reference lives in the docblock above, not in the copy.
description: 'Append-only audit trail of metadata write decisions.',

fields: {
/** Primary Key (UUID) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const SysMetadataCommitObject = ObjectSchema.create({
icon: 'git-commit',
isSystem: true,
managedBy: 'engine-owned',
description: 'Package-scoped commit log grouping a turn’s metadata changes (ADR-0067).',
// ADR-0067 — the reference lives in the docblock above, not in the copy.
description: 'Package-scoped commit log grouping a turn’s metadata changes.',

fields: {
/**
Expand Down
3 changes: 2 additions & 1 deletion packages/metadata-core/src/objects/sys-metadata.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ export const SysMetadataObject = ObjectSchema.create({
environment_id: Field.lookup('sys_environment', {
label: 'Environment (deprecated)',
required: false,
description: 'DEPRECATED. Overlays are environment rows (ADR-0131 D6); no tenant column.',
// ADR-0131 D6 — overlays are environment rows.
description: 'DEPRECATED. Overlays are environment rows; no tenant column.',
}),

/** Version number for optimistic concurrency */
Expand Down
185 changes: 185 additions & 0 deletions packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Setup copy carries no design-record vocabulary.
*
* An administrator reads three kinds of string this package ships: the
* sentence under a Setup page title (the object's `description`), a field's
* help tooltip (the field's `description`, extracted as `help`), and the Setup
* left nav (`SETUP_APP` plus `SETUP_NAV_CONTRIBUTIONS`). The generated bundles
* carry the same strings 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") means nothing to that reader: the reference
* belongs in the docblock or comment next to the string, never in the string.
*
* Population, all of it read from this checkout's source (no build artifact
* except `@objectstack/metadata-core`, a registered unaliased pair in
* `scripts/check-test-source-alias.mjs`):
*
* 1. every object schema this package exports, plus every one
* `@objectstack/metadata-core` exports (`sys_metadata_commit` included,
* which no bundle carries) — each `label`, `pluralLabel` and
* `description` anywhere in the definition;
* 2. every leaf of the four committed `objects` bundles;
* 3. the type-level `label` / `description` of the four `metadataForms`
* bundles — the sentence a Setup metadata page (Positions, Capabilities,
* Permission Sets) shows under its title. Section descriptions and field
* `helpText` of those forms are the spec's form copy and are not judged
* here;
* 4. `SETUP_APP` and this package's `SETUP_NAV_CONTRIBUTIONS`, and the
* hand-authored `apps.setup` subtree of each locale.
*
* Nav entries other packages contribute, and the service-messaging objects,
* are outside this package's import graph; `service-messaging` pins its own
* objects in `src/objects/admin-copy-vocabulary.test.ts`.
*/

import { describe, expect, it } from 'vitest';
import * as MetadataCore from '@objectstack/metadata-core';
import * as PlatformObjects from '../index.js';
import { SETUP_APP } from './setup.app.js';
import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js';
import { en } from './translations/en.js';
import { zhCN } from './translations/zh-CN.js';
import { jaJP } from './translations/ja-JP.js';
import { esES } from './translations/es-ES.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';
import { enMetadataForms } from './translations/en.metadata-forms.generated.js';
import { zhCNMetadataForms } from './translations/zh-CN.metadata-forms.generated.js';
import { jaJPMetadataForms } from './translations/ja-JP.metadata-forms.generated.js';
import { esESMetadataForms } from './translations/es-ES.metadata-forms.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']);

/** Every `label` / `pluralLabel` / `description` string under `node`, with its path. */
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);
}
}
}

/** Every string leaf under `node`, with its path. */
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';
}

function objectSchemas(...modules: Array<Record<string, unknown>>): Map<string, ObjectLike> {
const byName = new Map<string, ObjectLike>();
for (const mod of modules) {
for (const v of Object.values(mod)) if (isObjectSchema(v) && !byName.has(v.name)) byName.set(v.name, v);
}
return byName;
}

const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const;
const OBJECT_BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const;
const FORM_BUNDLES = {
en: enMetadataForms, 'zh-CN': zhCNMetadataForms, 'ja-JP': jaJPMetadataForms, 'es-ES': esESMetadataForms,
} as const;

describe('Setup copy carries no design-record vocabulary', () => {
it('the matcher recognises each kind of vocabulary it exists to keep out', () => {
// A matcher that matches nothing passes every assertion below; each kind
// must be seen on a sentence of the shape it was written for.
expect(vocabularyIn('Business Unit tree (ADR-0057).')).toEqual(['an ADR number']);
expect(vocabularyIn('Placement intent, decision D12.')).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('The Kernel API is additive.')).toEqual(['a runtime internal']);
expect(vocabularyIn('Your company structure, used to scope data and reports.')).toEqual([]);
});

it('object, field and view copy of every object this package and metadata-core export', () => {
const objects = objectSchemas(
PlatformObjects as unknown as Record<string, unknown>,
MetadataCore as unknown as Record<string, unknown>,
);
// The population is not allowed to shrink to nothing: the objects whose
// copy this suite was written against must still be in it.
for (const name of [
'sys_user', 'sys_business_unit', 'sys_notification', 'sys_sso_provider',
'sys_metadata', 'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_activation',
]) {
expect(objects.has(name), `${name} is in the population`).toBe(true);
}
expect(objects.size).toBeGreaterThanOrEqual(50);

const strings: Array<[string, string]> = [];
for (const [name, def] of objects) copyStrings(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(1000);
expect(offenders(strings)).toEqual([]);
});

it.each(Object.keys(FORM_BUNDLES))('the type-level label and description of the %s metadataForms bundle', (locale) => {
const forms = FORM_BUNDLES[locale as keyof typeof FORM_BUNDLES] as Record<string, { label?: string; description?: string }>;
const strings: Array<[string, string]> = [];
for (const [type, form] of Object.entries(forms)) {
if (typeof form.label === 'string') strings.push([`metadataForms.${type}.label`, form.label]);
if (typeof form.description === 'string') strings.push([`metadataForms.${type}.description`, form.description]);
}
expect(forms.capability?.description, 'the Capability page sentence is in the population').toBeTypeOf('string');
expect(offenders(strings)).toEqual([]);
});

it('the Setup app, its nav contributions, and every locale\'s apps.setup translations', () => {
const strings: Array<[string, string]> = [];
copyStrings(SETUP_APP, 'SETUP_APP', strings);
copyStrings(SETUP_NAV_CONTRIBUTIONS, 'SETUP_NAV_CONTRIBUTIONS', strings);
for (const [locale, data] of Object.entries(LOCALES)) {
leafStrings(data.apps?.setup, `${locale}.apps.setup`, strings);
}
expect(strings.some(([at]) => at.endsWith('nav_business_units.label'))).toBe(true);
expect(offenders(strings)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
role: {
label: "Platform Role",
help: "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2). To grant platform-admin standing, list the user's verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture an unscoped `admin_full_access` assignment in `sys_user_permission_set` also confers it."
help: "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it. To grant platform-admin standing, list the user's verified email in `OS_PLATFORM_OWNER_EMAIL`; under the `single` tenancy posture an unscoped `admin_full_access` assignment in `sys_user_permission_set` also confers it."
},
banned: {
label: "Banned",
Expand Down Expand Up @@ -97,11 +97,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
manager_id: {
label: "Manager",
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks."
},
primary_business_unit_id: {
label: "Primary Business Unit",
help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership."
help: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing. Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership."
},
source: {
label: "Identity Source",
Expand Down Expand Up @@ -537,7 +537,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
parent_organization_id: {
label: "Parent Organization",
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference."
},
sort_order: {
label: "Sort Order",
Expand Down Expand Up @@ -710,11 +710,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
business_unit_id: {
label: "Placement Business Unit",
help: "Business unit the invitee is placed under on acceptance (ADR-0105 D8). Must lie inside the issuer's delegated subtree."
help: "Business unit the invitee is placed under on acceptance. Must lie inside the issuer's delegated subtree."
},
positions: {
label: "Placement Positions",
help: "Position names assigned on acceptance (ADR-0105 D8). Every position's permission sets must be allowlisted by the issuer's adminScope."
help: "Position names assigned on acceptance. Every position's permission sets must be allowlisted by the issuer's adminScope."
}
},
_views: {
Expand Down Expand Up @@ -848,7 +848,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
sys_business_unit: {
label: "Business Unit",
pluralLabel: "Business Units",
description: "Canonical Business Unit tree — hierarchical org/data-partition node (company / division / department / region / office). ADR-0057 D2.",
description: "Your company structure — divisions, departments, regions and offices — used to scope data and reports.",
fields: {
name: {
label: "Name"
Expand Down Expand Up @@ -1797,7 +1797,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
domain_verified: {
label: "Domain Verified",
help: "Whether DNS ownership of the email domain has been proven (ADR-0135 D6). Set by “Verify Domain” after the DNS TXT record resolves. Managed by better-auth — not directly editable. Only enforced when domain verification is enabled for the environment."
help: "Whether DNS ownership of the email domain has been proven. Set by “Verify Domain” after the DNS TXT record resolves. Managed by better-auth — not directly editable. Only enforced when domain verification is enabled for the environment."
},
oidc_config: {
label: "OIDC Config",
Expand Down Expand Up @@ -2356,7 +2356,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
sys_notification: {
label: "Notification Event",
pluralLabel: "Notification Events",
description: "Notification events — one row per emit() (ADR-0030 Layer 2 ingress)",
description: "Every notification the platform sent, one row per event.",
fields: {
id: {
label: "Notification ID"
Expand Down Expand Up @@ -2984,7 +2984,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
environment_id: {
label: "Environment (deprecated)",
help: "DEPRECATED. Overlays are environment rows (ADR-0131 D6); no tenant column."
help: "DEPRECATED. Overlays are environment rows; no tenant column."
},
version: {
label: "Version"
Expand Down Expand Up @@ -3090,7 +3090,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
sys_metadata_audit: {
label: "Metadata Audit",
pluralLabel: "Metadata Audit",
description: "Append-only audit trail of metadata write decisions (ADR-0010).",
description: "Append-only audit trail of metadata write decisions.",
fields: {
id: {
label: "ID"
Expand Down
Loading
Loading