From 5ccfb70b38d9cf0834b35022d22a76b99c1e3690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 28 Jul 2026 07:01:37 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(sharing,driver-sql):=20a=20rule=20autho?= =?UTF-8?q?red=20in=20Setup=20actually=20applies=20=E2=80=94=20and=20switc?= =?UTF-8?q?hing=20it=20off=20actually=20withdraws=20access=20(#3821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Enabling a rule did nothing to the records already there.** Writing a `sys_sharing_rule` rebound the per-record hooks, which only makes the rule reach records written FROM THEN ON. An admin who created a rule and switched it on saw the recipient's list stay empty until somebody happened to touch each record. **Switching a rule off did not withdraw access.** Deactivating — or deleting — left every grant it had already issued in place, and boot backfill only reconciles ACTIVE rules, so those grants outlived restarts while the UI showed the rule as disabled. The reconcile existed but was reachable only through `POST /sharing/rules/:id/evaluate`, which the Console never calls and exposes no button for. Each non-system write to `sys_sharing_rule` now also reconciles that rule's grants, chained behind the existing rebind: insert/update run the same diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is inactive), and delete purges directly via the new `SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because the row is gone (`RULE_NOT_FOUND`), which is also why a rule deleted through the plain data API used to orphan its grants. Seeding and package bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already backfills those. Reconciliation is best-effort and never fails the authoring write. **An unsortable query lost its rows, not just its order.** `SqlDriver.find()` already recovered from a SELECT projection naming a missing column; the same failure one clause over — an unknown ORDER BY column — fell through to `return []`. Because `count()` is a separate statement, the endpoint answered 200 with `records: []` and `total: 3`: the rows are there, none are shown, nothing is logged. Same family as the `$`-param footgun closed by #2926. It surfaced through the sharing-rule recipient picker, whose client mangled `'name asc'` into a list of one-character column names (fixed in objectui). Rows now outrank their order: the retry ladder drops the projection first, then the sort, then gives up — an unsortable query returns rows UNORDERED instead of empty. Non-column errors still propagate. Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES bundles, which still described `recipient_type` in terms of `department` (the enum value is `business_unit`) and told admins to enter a queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES option labels for `position` / `unit_and_subordinates` were translated as "rol". Browser-verified against the showcase Console: enabling a rule immediately gave the recipient every matching record, and switching it off took them all away. Co-Authored-By: Claude Opus 5 --- ...haring-rule-unknown-sort-and-stale-help.md | 51 ++++++++++ ...sql-driver-unknown-column-recovery.test.ts | 98 +++++++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 31 ++++-- .../plugin-sharing/src/rule-rebind.test.ts | 80 +++++++++++++++ .../plugin-sharing/src/sharing-plugin.ts | 55 ++++++++++- .../src/sharing-rule-service.ts | 11 +++ .../translations/es-ES.objects.generated.ts | 8 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- 9 files changed, 325 insertions(+), 17 deletions(-) create mode 100644 .changeset/sharing-rule-unknown-sort-and-stale-help.md create mode 100644 packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts diff --git a/.changeset/sharing-rule-unknown-sort-and-stale-help.md b/.changeset/sharing-rule-unknown-sort-and-stale-help.md new file mode 100644 index 0000000000..e99a117e11 --- /dev/null +++ b/.changeset/sharing-rule-unknown-sort-and-stale-help.md @@ -0,0 +1,51 @@ +--- +"@objectstack/driver-sql": patch +"@objectstack/plugin-sharing": patch +--- + +fix(driver-sql,sharing): an unsortable query loses its ORDER BY, not its rows (#3821) + +`SqlDriver.find()` already recovered from a SELECT projection naming a column +the table lacks (retry with `select('*')`, the unknown field is simply absent +from each row). The identical failure one clause over — an **ORDER BY** column +the table lacks — fell through to `return []`. Because `count()` is a separate +statement, the list endpoint answered `HTTP 200` with `records: []` and +`total: 3`: the rows are there, none are shown, nothing is logged. Same family +as the `$`-param footgun closed by #2926. + +It surfaced through the Console's sharing-rule **recipient picker**, which +never listed a single candidate. The client mangled `'name asc'` into +`0 n,1 a,2 m,…` (fixed separately in objectui) and the driver turned that into +"no users exist", so no sharing rule could be authored from the UI at all. + +Rows now outrank their order: the retry ladder drops the projection first (the +likelier culprit and the cheaper thing to lose), then the sort, then gives up. +A query that cannot be sorted comes back **unordered instead of empty**. Errors +that are not about an unknown column still propagate untouched. + +**A rule authored in Setup now actually applies — and switching it off actually +withdraws access.** Writing a `sys_sharing_rule` rebound the per-record hooks, +which only makes the rule reach records written FROM THEN ON. So an admin who +created a rule and enabled it saw nothing happen: the recipient's list stayed +empty until somebody happened to touch each record. The reverse was worse — +switching a rule OFF, or deleting it, left every grant it had already issued in +place, and boot backfill only reconciles ACTIVE rules, so those grants outlived +restarts while the UI displayed the rule as disabled. The reconcile was reachable +only through `POST /sharing/rules/:id/evaluate`, which the Console never calls. + +Each non-system write to `sys_sharing_rule` now also reconciles that rule's +grants, chained behind the existing rebind: insert/update run the same +diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is +inactive), and delete purges directly via the new +`SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because +the row is already gone (`RULE_NOT_FOUND`), which is also why a rule deleted +through the plain data API used to orphan its grants. Seeding and package +bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already +backfills those. Reconciliation is best-effort and never fails the write. + +Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES +translation bundles, which still described `recipient_type` in terms of +`department` (the enum value is `business_unit`) and told admins to enter a +queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES +option labels for `position` / `unit_and_subordinates` were translated as +"rol" — corrected to "Puesto" / "Unidad de negocio y subordinados". diff --git a/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts new file mode 100644 index 0000000000..0c7fe247ab --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#3821 — `find()` must not turn an unknown column into "no rows". + * + * A SELECT projection naming a column the table lacks already had a retry + * (select `*` and let the missing field simply be absent). An unknown ORDER BY + * column did not: it fell through to `return []`, so the page came back empty + * while `count()` — a separate statement — still reported the true total. The + * UI reads that as "the records vanished". It surfaced through the sharing-rule + * recipient picker, whose client mangled `'name asc'` into a list of + * one-character column names. + * + * Rows matter more than their order, so an unsortable query returns the rows + * unordered. These tests pin that, and that a genuine SQL error still throws. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +describe('SqlDriver find() recovers from unknown columns (objectstack#3821)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + await driver.initObjects([ + { + name: 'member', + fields: { + name: { type: 'string' }, + rank: { type: 'integer' }, + }, + }, + ]); + + await driver.create('member', { id: 'm1', name: 'Carol', rank: 3 }, { bypassTenantAudit: true }); + await driver.create('member', { id: 'm2', name: 'Alice', rank: 1 }, { bypassTenantAudit: true }); + await driver.create('member', { id: 'm3', name: 'Bob', rank: 2 }, { bypassTenantAudit: true }); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('returns the rows unordered when ORDER BY names a column the table lacks', async () => { + const rows = await driver.find('member', { + orderBy: [{ field: 'nosuchfield', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm2', 'm3']); + }); + + it('still sorts when every ORDER BY column exists', async () => { + const rows = await driver.find('member', { + orderBy: [{ field: 'rank', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.name)).toEqual(['Alice', 'Bob', 'Carol']); + }); + + it('drops only the unknown clause when a known and an unknown sort are mixed', async () => { + // knex emits both columns in one ORDER BY, so the whole clause has to go; + // what must NOT happen is losing the rows. + const rows = await driver.find('member', { + orderBy: [{ field: 'rank', order: 'asc' }, { field: 'nosuchfield', order: 'desc' }], + } as any); + + expect(rows).toHaveLength(3); + }); + + it('recovers from an unknown SELECT projection and an unknown sort together', async () => { + const rows = await driver.find('member', { + fields: ['name', 'nosuchfield'], + orderBy: [{ field: 'alsomissing', order: 'asc' }], + } as any); + + expect(rows).toHaveLength(3); + expect(rows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob', 'Carol']); + }); + + it('keeps honouring the WHERE clause while recovering', async () => { + const rows = await driver.find('member', { + where: { rank: { $gte: 2 } }, + orderBy: [{ field: 'nosuchfield', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm3']); + }); + + it('propagates errors that are not about an unknown column', async () => { + await expect(driver.find('no_such_table', {} as any)).rejects.toThrow(); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index c564f1c237..16c452b084 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -803,7 +803,8 @@ export class SqlDriver implements IDataDriver { async find(object: string, query: QueryAST, options?: DriverOptions): Promise { // Build everything EXCEPT the SELECT list, so the unknown-column retry // below can rebuild without re-deriving where/order/pagination. - const buildBase = () => { + const buildBase = (opts?: { withOrderBy?: boolean }) => { + const withOrderBy = opts?.withOrderBy !== false; const b = this.getBuilder(object, options); this.applyTenantScope(b, object, options); @@ -813,7 +814,7 @@ export class SqlDriver implements IDataDriver { } // ORDER BY - if (query.orderBy && Array.isArray(query.orderBy)) { + if (withOrderBy && query.orderBy && Array.isArray(query.orderBy)) { for (const item of query.orderBy) { if (item.field) { b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || 'asc'); @@ -858,15 +859,31 @@ export class SqlDriver implements IDataDriver { // only fires when the object's schema is populated in the registry — // this driver backstop holds even when it isn't (notably the cloud // multi-tenant runtime, where the projection otherwise zeroes the list). - if (query.fields) { + // + // An unknown ORDER BY column is the same footgun one clause over + // (objectstack#3821): a client that sorts by a field the table lacks + // used to get an empty page with a non-zero `total` — "the rows are + // there but none are shown". Rows matter more than their order, so + // drop the sort and return them unordered rather than nothing. Ladder: + // projection first (it is the likelier culprit and the cheaper thing + // to lose), then the sort, then give up. + const retries: Array<() => any> = []; + if (query.fields) retries.push(() => buildBase().select('*')); + if (query.orderBy && Array.isArray(query.orderBy) && query.orderBy.length > 0) { + retries.push(() => buildBase({ withOrderBy: false }).select('*')); + } + results = []; + let recovered = false; + for (const retry of retries) { try { - results = await buildBase().select('*'); + results = await retry(); + recovered = true; + break; } catch { - return []; + // Try the next, broader fallback. } - } else { - return []; } + if (!recovered) return []; } else { throw error; } diff --git a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts index a0d2d2d1e6..90bfd0af2e 100644 --- a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts +++ b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts @@ -68,6 +68,8 @@ describe('SharingServicePlugin sys_sharing_rule data-change rebind (#2592)', () rules = []; ruleService = { listRules: vi.fn(async () => rules), + evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: 0 })), + revokeRuleGrants: vi.fn(async () => 0), }; (plugin as any).ruleService = ruleService; (plugin as any).bindRuleRebindTriggers(engine, makeCtx()); @@ -148,3 +150,81 @@ describe('SharingServicePlugin sys_sharing_rule data-change rebind (#2592)', () expect(new Set(bound.map((h) => h.options.object))).toEqual(new Set(['beta'])); }); }); + +/** + * objectstack#3821 — rebinding alone only makes a rule apply to records written + * from now on. A rule authored in Setup did nothing to the records already + * there, and one switched OFF (or deleted) kept every grant it had issued — + * boot backfill only reconciles ACTIVE rules, so those grants outlived + * restarts while the UI showed the rule as disabled. + */ +describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () => { + let engine: ReturnType; + let plugin: SharingServicePlugin; + let ruleService: AnyRecord; + let logger: AnyRecord; + + beforeEach(() => { + engine = makeEngine(); + plugin = new SharingServicePlugin(); + ruleService = { + listRules: vi.fn(async () => []), + evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 2, expandedUsers: 1, grantsCreated: 2, grantsUpdated: 0, grantsRevoked: 0 })), + revokeRuleGrants: vi.fn(async () => 2), + }; + (plugin as any).ruleService = ruleService; + const ctx = makeCtx(); + logger = ctx.logger; + (plugin as any).bindRuleRebindTriggers(engine, ctx); + }); + + it('backfills existing records when a rule is created', async () => { + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'r1' } }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('reconciles on update — this is what withdraws access when a rule is switched off', async () => { + // `evaluateRule` purges the grants itself when the rule is inactive, so the + // plugin does not need to branch on `active` here. + await engine.fire('afterUpdate', 'sys_sharing_rule', { result: { id: 'r1', active: false } }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('purges grants on delete rather than evaluating a row that no longer exists', async () => { + await engine.fire('afterDelete', 'sys_sharing_rule', { input: { id: 'r1' } }); + expect(ruleService.revokeRuleGrants).toHaveBeenCalledWith('r1'); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + }); + + it('skips system-context writes — boot backfill owns those', async () => { + await engine.fire('afterInsert', 'sys_sharing_rule', { + result: { id: 'r1' }, + session: { isSystem: true }, + }); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + }); + + it('never fails the authoring write when reconciliation throws', async () => { + ruleService.evaluateRule = vi.fn(async () => { throw new Error('db gone'); }); + await expect( + engine.fire('afterUpdate', 'sys_sharing_rule', { result: { id: 'r1' } }), + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('does nothing when the write carries no rule id', async () => { + await engine.fire('afterUpdate', 'sys_sharing_rule', {}); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + expect(ruleService.revokeRuleGrants).not.toHaveBeenCalled(); + }); + + it('serializes reconciles behind the rebind chain', async () => { + const order: string[] = []; + ruleService.listRules = vi.fn(async () => { order.push('rebind'); return []; }); + ruleService.evaluateRule = vi.fn(async () => { order.push('reconcile'); return {} as any; }); + + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'r1' } }); + + expect(order).toEqual(['rebind', 'reconcile']); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 7adc22ba42..073d55491a 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -144,6 +144,22 @@ export class SharingServicePlugin implements Plugin { * same lifecycle pipeline (awaited, so the rule is enforceable the moment * the authoring call returns), but never fails the write — a rebind * failure logs and leaves the previous bindings in place. + * + * [#3821] The rebind alone only makes the rule apply to records written + * FROM NOW ON. Authoring in the Setup UI therefore looked inert: an admin + * created a rule, switched it on, and the recipient still saw nothing — + * the rule only reached a record once somebody happened to touch it. Worse + * in the other direction, switching a rule OFF (or deleting it) left every + * grant it had already issued in place, and boot backfill only reconciles + * ACTIVE rules, so those grants survived restarts forever: the UI said + * "disabled" while the access was still live. + * + * So each write now also reconciles THAT rule's grants — the same + * `evaluateRule` the REST `/sharing/rules/:id/evaluate` endpoint runs, which + * is diff-based and purges when the rule is inactive. Deletes can't go + * through it (the row is gone, `RULE_NOT_FOUND`), so they purge directly. + * System-context writes are skipped: seeding and package bootstrap write + * with `isSystem`, and `kernel:bootstrapped` already backfills those. */ private bindRuleRebindTriggers(engine: any, ctx: PluginContext): void { const scheduleRebind = (): Promise => { @@ -159,7 +175,27 @@ export class SharingServicePlugin implements Plugin { this.ruleRebindChain = run.catch(() => undefined); return run; }; - const handler = async () => { + /** + * Reconcile the written rule's grants, chained behind the rebind so two + * rapid writes can't interleave their reconciles. Best-effort: a failure + * logs and leaves the previous grants alone — it must never fail the + * authoring write. + */ + const scheduleReconcile = (ruleId: string, deleted: boolean): Promise => { + const run = this.ruleRebindChain.then(async () => { + const ruleService = this.ruleService; + if (!ruleService) return; + if (deleted) { + await ruleService.revokeRuleGrants(ruleId); + return; + } + await ruleService.evaluateRule(ruleId, { isSystem: true } as any); + }); + this.ruleRebindChain = run.catch(() => undefined); + return run; + }; + + const makeHandler = (deleted: boolean) => async (hookCtx: any) => { try { await scheduleRebind(); } catch (err: any) { @@ -167,9 +203,24 @@ export class SharingServicePlugin implements Plugin { error: err?.message, }); } + // Seeding / package bootstrap write with `isSystem`; `kernel:bootstrapped` + // backfills those, so reconciling here would only duplicate that work. + if (hookCtx?.session?.isSystem) return; + const data = hookCtx?.result ?? hookCtx?.input?.data ?? {}; + const ruleId = String(data?.id ?? hookCtx?.input?.id ?? ''); + if (!ruleId) return; + try { + await scheduleReconcile(ruleId, deleted); + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: sharing-rule grant reconcile failed — grants left unchanged', { + rule: ruleId, + error: err?.message, + }); + } }; + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { - engine.registerHook(event, handler, { + engine.registerHook(event, makeHandler(event === 'afterDelete'), { object: 'sys_sharing_rule', packageId: RULE_REBIND_TRIGGER_PACKAGE, priority: 200, diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 5121aa550a..c93b03df64 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -231,6 +231,17 @@ export class SharingRuleService implements ISharingRuleService { return this.reconcile(rule, matches, users); } + /** + * Revoke every grant this rule materialised, without needing the rule row to + * still exist. `evaluateRule` throws `RULE_NOT_FOUND` once the row is gone, + * so a rule DELETED through the plain data API (which is what the Setup UI's + * delete action issues — it never reaches {@link deleteRule}) would otherwise + * leave its grants behind forever (objectstack#3821). + */ + async revokeRuleGrants(ruleId: string): Promise { + return this.purgeRuleGrants(ruleId); + } + async evaluateAllForRecord( object: string, recordId: string, diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index de1566ca62..0972c05a80 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -131,18 +131,18 @@ export const esESObjects: NonNullable = { }, recipient_type: { label: "Tipo de destinatario", - help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `department` recorre el árbol parent_business_unit_id; `team` es plano (better-auth).", + help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `business_unit` recorre el árbol parent_business_unit_id; `team` es plano (better-auth); `position` se expande a quienes ocupan el puesto (los puestos son planos, ADR-0090 D3); `unit_and_subordinates` expande la unidad de negocio indicada MÁS los miembros de todas sus unidades descendientes a través del árbol sys_business_unit (ADR-0057 D5).", options: { user: "Usuario", team: "Equipo", business_unit: "Unidad de negocio", - position: "posición", - unit_and_subordinates: "Rol y subordinados" + position: "Puesto", + unit_and_subordinates: "Unidad de negocio y subordinados" } }, recipient_id: { label: "Destinatario", - help: "ID de departamento / ID de equipo / nombre del rol / nombre de la cola / ID del usuario según recipient_type." + help: "ID de unidad de negocio / ID de equipo / nombre del puesto / ID del usuario según recipient_type." }, access_level: { label: "Nivel de acceso", diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index e35b851c0f..7dfc839d10 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -131,7 +131,7 @@ export const jaJPObjects: NonNullable = { }, recipient_type: { label: "受信者タイプ", - help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`department` は parent_business_unit_id ツリーをたどります。`team` はフラット(better-auth)。", + help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`business_unit` は parent_business_unit_id ツリーをたどります。`team` はフラット(better-auth)。`position` はそのポジションの在任者に展開されます(ポジションはフラット、ADR-0090 D3)。`unit_and_subordinates` は指定したビジネスユニットに加え、sys_business_unit ツリー上の全下位ユニットのメンバーにも展開されます(ADR-0057 D5)。", options: { user: "ユーザー", team: "チーム", @@ -142,7 +142,7 @@ export const jaJPObjects: NonNullable = { }, recipient_id: { label: "受信者", - help: "recipient_type に応じた部門 ID / チーム ID / ロール名 / キュー名 / ユーザー ID" + help: "recipient_type に応じたビジネスユニット ID / チーム ID / ポジション名 / ユーザー ID" }, access_level: { label: "アクセスレベル", diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 5ca5ac63e5..88c7ee205a 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -131,7 +131,7 @@ export const zhCNObjects: NonNullable = { }, recipient_type: { label: "接收方类型", - help: "接收访问权限的主体类型——求值时会展开为用户授权。`department` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth)。", + help: "接收访问权限的主体类型——求值时会展开为用户授权。`business_unit` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth);`position` 展开为该岗位的任职人(岗位为扁平结构,ADR-0090 D3);`unit_and_subordinates` 会沿 sys_business_unit 树展开指定业务单元及其全部下级单元的成员(ADR-0057 D5)。", options: { user: "用户", team: "团队", @@ -142,7 +142,7 @@ export const zhCNObjects: NonNullable = { }, recipient_id: { label: "接收方", - help: "根据 recipient_type 填写 业务单元 id / 团队 id / 岗位名 / 队列名 / 用户 id" + help: "根据 recipient_type 填写 业务单元 id / 团队 id / 岗位名 / 用户 id" }, access_level: { label: "访问级别", From bc9e945497590809f79e42daee27f4f894ce25f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 28 Jul 2026 08:02:13 -0700 Subject: [PATCH 2/2] fix(sharing): write the sharing-rule help text for the admin who reads it (#3821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field `description`s on `sys_sharing_rule` render as help text under each input in Setup. They were written as notes for the next engineer: 接收访问权限的主体类型——求值时会展开为用户授权。`business_unit` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth);`position` 展开为该岗位的任职人(岗位为扁平结构,ADR-0090 D3)…(ADR-0057 D5)。 ADR numbers, table and column names, a third-party library, and enum machine values the dropdown never displays — it shows 用户 / 团队 / 业务单元 / 岗位. Several were also stale, some of them because of this very issue's UI work: `recipient_id` still said "fill in a business-unit id / team id / position name" after it became a record picker; `object_name` still said "short object name (e.g. opportunity, account)" after it became an object picker; `criteria_json` still described hand-written `FilterCondition` JSON after it became a visual builder — and carried "(FilterCondition JSON)" in its LABEL. All of it rewritten for the reader who actually sees it, in en / zh-CN / ja-JP / es-ES. The engine detail was already in the object's doc comment, which is where it stays; a comment on the first field now says so, so the next author doesn't put it back. `active` can finally state what it does — turning it off withdraws the access — which only became true with the reconcile fix in this branch. The list page's subtitle and the `managed_by` / `customized` help got the same treatment. Browser-verified in the showcase Console: no ADR, table, column, enum or library name survives anywhere in the create dialog, the detail page or the list header. Co-Authored-By: Claude Opus 5 --- ...haring-rule-unknown-sort-and-stale-help.md | 13 ++++++++ .../src/objects/sys-sharing-rule.object.ts | 30 +++++++++++-------- .../src/translations/en.objects.generated.ts | 20 ++++++------- .../translations/es-ES.objects.generated.ts | 20 ++++++------- .../translations/ja-JP.objects.generated.ts | 20 ++++++------- .../translations/zh-CN.objects.generated.ts | 20 ++++++------- 6 files changed, 71 insertions(+), 52 deletions(-) diff --git a/.changeset/sharing-rule-unknown-sort-and-stale-help.md b/.changeset/sharing-rule-unknown-sort-and-stale-help.md index e99a117e11..bfcc9c545f 100644 --- a/.changeset/sharing-rule-unknown-sort-and-stale-help.md +++ b/.changeset/sharing-rule-unknown-sort-and-stale-help.md @@ -43,6 +43,19 @@ through the plain data API used to orphan its grants. Seeding and package bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already backfills those. Reconciliation is best-effort and never fails the write. +**The dialog's help text was engineering notes, shown to tenant admins.** The +field descriptions on `sys_sharing_rule` render under each input in Setup, and +they cited ADR numbers, table and column names (`parent_business_unit_id`, +`sys_business_unit`), enum machine values the dropdown never shows +(`business_unit`, `team`), a third-party library (better-auth), and engine +vocabulary ("evaluation", "lifecycle"). Several were also stale: they still told +admins to type an id or hand-write a `FilterCondition` after those inputs became +a record picker and a visual builder. Rewritten for the reader who actually sees +them — the implementation detail was already in the object's doc comment, which +is where it stays. `criteria_json`'s LABEL loses its "(FilterCondition JSON)" +suffix for the same reason, and `active` can finally say what it now does: +turning it off withdraws the access. + Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES translation bundles, which still described `recipient_type` in terms of `department` (the enum value is `business_unit`) and told admins to enter a diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 0bd3a29bfc..099b1dfb28 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -37,7 +37,7 @@ export const SysSharingRule = ObjectSchema.create({ // We still recommend `defineSharingRule({...})` for repo-controlled // baselines, but admins can safely create/edit/delete from the UI. userActions: { create: true, edit: true, delete: true, import: false }, - description: 'Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.', + description: 'Grants a group of people access to the records that match a condition.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', @@ -99,7 +99,13 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Name', required: true, maxLength: 100, - description: 'Unique snake_case rule name', + // Field `description`s on this object are ADMIN-FACING help text rendered + // under each input in Setup — not notes for the next engineer. They must + // not name tables, columns, enum values, ADRs or third-party libraries: + // the reader is a tenant admin who sees only the labels in the dropdown + // (objectstack#3821). Implementation detail belongs in the doc comments + // above, where it already is. + description: 'Identifies the rule. Lowercase letters, digits and underscores only.', group: 'Identity', }), @@ -124,12 +130,12 @@ export const SysSharingRule = ObjectSchema.create({ // instead of a free-text machine-name input. Falls back to a text input // when the `field:object-ref` widget is unavailable. widget: 'object-ref', - description: 'Short object name (e.g. opportunity, account)', + description: 'The object whose records this rule shares.', group: 'Target', }), criteria_json: Field.textarea({ - label: 'Criteria (FilterCondition JSON)', + label: 'Criteria', required: false, // Rendered as a visual criteria builder scoped to the selected object's // fields (dependsOn: object_name), storing the same JSON FilterCondition. @@ -137,7 +143,7 @@ export const SysSharingRule = ObjectSchema.create({ // editable. Falls back to a textarea when the widget is unavailable. widget: 'filter-condition', dependsOn: ['object_name'], - description: 'JSON FilterCondition matched against records of object_name. Empty = match all.', + description: 'Which records to share. Leave empty to share every record of the object.', group: 'Target', }), @@ -150,7 +156,9 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Recipient Type', required: true, defaultValue: 'business_unit', - description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position\'s holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit\'s members via the sys_business_unit tree (ADR-0057 D5).', + // The engine detail this used to spell out (which tree is walked, which + // expansion is flat, the ADRs behind each) lives in the class doc above. + description: 'Who receives access. Picking a team, business unit or position gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick.', group: 'Recipient', }, ), @@ -166,7 +174,7 @@ export const SysSharingRule = ObjectSchema.create({ // back to a text input when the widget is unavailable. widget: 'recipient-picker', dependsOn: ['recipient_type'], - description: 'business-unit id / team id / position name / user id depending on recipient_type', + description: 'The specific user, team, business unit or position that receives access.', group: 'Recipient', }), @@ -184,7 +192,7 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Active', required: false, defaultValue: true, - description: 'Only active rules participate in lifecycle evaluation', + description: 'Turn off to withdraw the access this rule granted, without deleting the rule.', group: 'Lifecycle', }), @@ -203,8 +211,7 @@ export const SysSharingRule = ObjectSchema.create({ readonly: true, defaultValue: 'admin', description: - 'Record provenance (unified tri-state, A4 #2920): platform = framework built-in / ' + - 'package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.', + 'Where this rule came from: built into the platform, installed with an app, or created here in Setup.', options: [ { value: 'platform', label: 'Platform' }, { value: 'package', label: 'Package' }, @@ -219,8 +226,7 @@ export const SysSharingRule = ObjectSchema.create({ readonly: true, defaultValue: false, description: - 'Set when an admin edits a package-declared rule; boot seeding will no longer ' + - 'overwrite the row (deactivations survive redeploys). Meaningless on admin rows.', + 'Set once you edit a rule that came with an app, so your changes are kept when the app is updated.', group: 'System', }), diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 845553397a..cfd76042a0 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -102,7 +102,7 @@ export const enObjects: NonNullable = { sys_sharing_rule: { label: "Sharing Rule", pluralLabel: "Sharing Rules", - description: "Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.", + description: "Grants a group of people access to the records that match a condition.", fields: { id: { label: "Rule ID" @@ -113,7 +113,7 @@ export const enObjects: NonNullable = { }, name: { label: "Name", - help: "Unique snake_case rule name" + help: "Identifies the rule. Lowercase letters, digits and underscores only." }, label: { label: "Display Label" @@ -123,15 +123,15 @@ export const enObjects: NonNullable = { }, object_name: { label: "Object", - help: "Short object name (e.g. opportunity, account)" + help: "The object whose records this rule shares." }, criteria_json: { - label: "Criteria (FilterCondition JSON)", - help: "JSON FilterCondition matched against records of object_name. Empty = match all." + label: "Criteria", + help: "Which records to share. Leave empty to share every record of the object." }, recipient_type: { label: "Recipient Type", - help: "Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position's holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit's members via the sys_business_unit tree (ADR-0057 D5).", + help: "Who receives access. Picking a team, business unit or position gives access to everyone in it. \"Business unit and subordinates\" also covers every unit below the one you pick.", options: { user: "user", team: "team", @@ -142,7 +142,7 @@ export const enObjects: NonNullable = { }, recipient_id: { label: "Recipient", - help: "business unit id / team id / position name / user id depending on recipient_type" + help: "The specific user, team, business unit or position that receives access." }, access_level: { label: "Access Level", @@ -154,11 +154,11 @@ export const enObjects: NonNullable = { }, active: { label: "Active", - help: "Only active rules participate in lifecycle evaluation" + help: "Turn off to withdraw the access this rule granted, without deleting the rule." }, managed_by: { label: "Managed By", - help: "Record provenance (unified tri-state, A4 #2920): platform = framework built-in / package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.", + help: "Where this rule came from: built into the platform, installed with an app, or created here in Setup.", options: { platform: "Platform", package: "Package", @@ -167,7 +167,7 @@ export const enObjects: NonNullable = { }, customized: { label: "Customized", - help: "Set when an admin edits a package-declared rule; boot seeding will no longer overwrite the row (deactivations survive redeploys). Meaningless on admin rows." + help: "Set once you edit a rule that came with an app, so your changes are kept when the app is updated." }, created_at: { label: "Created At" diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 0972c05a80..17dd86dee6 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -102,7 +102,7 @@ export const esESObjects: NonNullable = { sys_sharing_rule: { label: "Regla de compartición", pluralLabel: "Reglas de compartición", - description: "Regla de compartición declarativa que materializa automáticamente concesiones de sys_record_share. Se define mediante defineSharingRule() en código o con el generador de criterios de Studio.", + description: "Da acceso a un grupo de personas a los registros que cumplen una condición.", fields: { id: { label: "ID de regla" @@ -113,7 +113,7 @@ export const esESObjects: NonNullable = { }, name: { label: "Nombre", - help: "Nombre de regla snake_case único." + help: "Identifica la regla. Solo minúsculas, dígitos y guiones bajos." }, label: { label: "Nombre visible" @@ -123,15 +123,15 @@ export const esESObjects: NonNullable = { }, object_name: { label: "Objeto", - help: "Nombre corto del objeto (p. ej. opportunity, account)." + help: "El objeto cuyos registros comparte esta regla." }, criteria_json: { - label: "Criterios (JSON de FilterCondition)", - help: "FilterCondition JSON comparado con los registros de object_name. Vacío = coincide con todos." + label: "Criterios", + help: "Qué registros se comparten. Déjalo vacío para compartir todos los registros del objeto." }, recipient_type: { label: "Tipo de destinatario", - help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `business_unit` recorre el árbol parent_business_unit_id; `team` es plano (better-auth); `position` se expande a quienes ocupan el puesto (los puestos son planos, ADR-0090 D3); `unit_and_subordinates` expande la unidad de negocio indicada MÁS los miembros de todas sus unidades descendientes a través del árbol sys_business_unit (ADR-0057 D5).", + help: "Quién recibe el acceso. Al elegir un equipo, una unidad de negocio o un puesto, lo reciben todas las personas que lo integran. «Unidad de negocio y subordinadas» abarca además todas las unidades por debajo de la elegida.", options: { user: "Usuario", team: "Equipo", @@ -142,7 +142,7 @@ export const esESObjects: NonNullable = { }, recipient_id: { label: "Destinatario", - help: "ID de unidad de negocio / ID de equipo / nombre del puesto / ID del usuario según recipient_type." + help: "El usuario, equipo, unidad de negocio o puesto concreto que recibe el acceso." }, access_level: { label: "Nivel de acceso", @@ -154,11 +154,11 @@ export const esESObjects: NonNullable = { }, active: { label: "Activo", - help: "Solo las reglas activas participan en la evaluación del ciclo de vida." + help: "Desactívala para retirar el acceso que concedió, sin eliminar la regla." }, managed_by: { label: "Gestionado por", - help: "Procedencia del registro (tri-estado unificado, A4 #2920): platform = integrado en el framework / package = declarado por app o paquete (sembrado al arrancar) / admin = creado por el inquilino en Setup.", + help: "De dónde viene esta regla: integrada en la plataforma, instalada con una app o creada aquí.", options: { platform: "Plataforma", package: "Paquete", @@ -167,7 +167,7 @@ export const esESObjects: NonNullable = { }, customized: { label: "Personalizado", - help: "Se establece cuando un administrador edita una regla declarada por un paquete; a partir de entonces el sembrado de arranque deja de sobrescribir la fila (las desactivaciones sobreviven a los redespliegues). Sin sentido en filas admin." + help: "Se marca al editar una regla que vino con una app, para conservar tus cambios cuando la app se actualice." }, created_at: { label: "Creado el" diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 7dfc839d10..1d4cb41e9c 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -102,7 +102,7 @@ export const jaJPObjects: NonNullable = { sys_sharing_rule: { label: "共有ルール", pluralLabel: "共有ルール", - description: "sys_record_share 付与を自動マテリアライズする宣言的共有ルール。コードの defineSharingRule() または Studio の条件ビルダーで作成します。", + description: "条件に一致するレコードへのアクセスを、指定した人たちに与えます。", fields: { id: { label: "ルール ID" @@ -113,7 +113,7 @@ export const jaJPObjects: NonNullable = { }, name: { label: "名前", - help: "一意の snake_case ルール名" + help: "ルールの識別名。半角小文字・数字・アンダースコアのみ使用できます。" }, label: { label: "表示名" @@ -123,15 +123,15 @@ export const jaJPObjects: NonNullable = { }, object_name: { label: "オブジェクト", - help: "短いオブジェクト名(例: opportunity、account)" + help: "どのオブジェクトのレコードを共有するか。" }, criteria_json: { - label: "条件(FilterCondition JSON)", - help: "object_name のレコードに対してマッチする JSON FilterCondition。空 = すべてにマッチ。" + label: "条件", + help: "どのレコードを共有するか。空欄にすると、そのオブジェクトのすべてのレコードを共有します。" }, recipient_type: { label: "受信者タイプ", - help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`business_unit` は parent_business_unit_id ツリーをたどります。`team` はフラット(better-auth)。`position` はそのポジションの在任者に展開されます(ポジションはフラット、ADR-0090 D3)。`unit_and_subordinates` は指定したビジネスユニットに加え、sys_business_unit ツリー上の全下位ユニットのメンバーにも展開されます(ADR-0057 D5)。", + help: "誰にアクセスを与えるか。チーム・事業単位・役職を選ぶと、そこに属する全員がアクセスできます。「事業単位と下位組織」を選ぶと、その配下のすべての単位も対象になります。", options: { user: "ユーザー", team: "チーム", @@ -142,7 +142,7 @@ export const jaJPObjects: NonNullable = { }, recipient_id: { label: "受信者", - help: "recipient_type に応じたビジネスユニット ID / チーム ID / ポジション名 / ユーザー ID" + help: "アクセスを与える具体的なユーザー・チーム・事業単位・役職。" }, access_level: { label: "アクセスレベル", @@ -154,11 +154,11 @@ export const jaJPObjects: NonNullable = { }, active: { label: "有効", - help: "有効なルールのみがライフサイクル評価に参加します" + help: "オフにすると、このルールが与えたアクセスをただちに取り消します(ルール自体は残ります)。" }, managed_by: { label: "管理元", - help: "レコードの出所(統一三状態、A4 #2920): platform = フレームワーク組み込み / package = アプリ・パッケージ宣言(起動時シード)/ admin = テナントが Setup で作成。", + help: "このルールの出所:プラットフォーム内蔵、アプリと一緒に導入、またはここで作成。", options: { platform: "プラットフォーム", package: "パッケージ", @@ -167,7 +167,7 @@ export const jaJPObjects: NonNullable = { }, customized: { label: "カスタマイズ済み", - help: "管理者がパッケージ宣言のルールを編集すると設定されます。以後、起動時シードはこの行を上書きしません(無効化は再デプロイ後も保持)。admin 行では意味を持ちません。" + help: "アプリ付属のルールを編集すると設定され、アプリ更新時も変更内容が保持されます。" }, created_at: { label: "作成日時" diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 88c7ee205a..ba201682c4 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -102,7 +102,7 @@ export const zhCNObjects: NonNullable = { sys_sharing_rule: { label: "共享规则", pluralLabel: "共享规则", - description: "声明式共享规则,会自动生成 sys_record_share 授权。可在代码中通过 defineSharingRule() 编写,或在 Studio 条件构建器中维护。", + description: "把符合条件的记录,授权给一组人访问。", fields: { id: { label: "规则 ID" @@ -113,7 +113,7 @@ export const zhCNObjects: NonNullable = { }, name: { label: "名称", - help: "唯一的 snake_case 规则名称" + help: "规则的标识。只能使用小写字母、数字和下划线。" }, label: { label: "显示标签" @@ -123,15 +123,15 @@ export const zhCNObjects: NonNullable = { }, object_name: { label: "对象", - help: "短对象名(例如 opportunity、account)" + help: "要共享哪个对象的记录。" }, criteria_json: { - label: "条件(FilterCondition JSON)", - help: "针对 object_name 记录匹配的 JSON FilterCondition。为空表示匹配全部。" + label: "条件", + help: "共享哪些记录。留空表示共享该对象的全部记录。" }, recipient_type: { label: "接收方类型", - help: "接收访问权限的主体类型——求值时会展开为用户授权。`business_unit` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth);`position` 展开为该岗位的任职人(岗位为扁平结构,ADR-0090 D3);`unit_and_subordinates` 会沿 sys_business_unit 树展开指定业务单元及其全部下级单元的成员(ADR-0057 D5)。", + help: "把访问权限给谁。选择团队、业务单元或岗位时,其中的每个人都会获得访问权限;选择「业务单元及下级」还会包含该单元下属的所有单元。", options: { user: "用户", team: "团队", @@ -142,7 +142,7 @@ export const zhCNObjects: NonNullable = { }, recipient_id: { label: "接收方", - help: "根据 recipient_type 填写 业务单元 id / 团队 id / 岗位名 / 用户 id" + help: "具体把访问权限给哪一个用户、团队、业务单元或岗位。" }, access_level: { label: "访问级别", @@ -154,11 +154,11 @@ export const zhCNObjects: NonNullable = { }, active: { label: "启用", - help: "只有启用的规则才会参与生命周期求值" + help: "关闭即收回本规则已授予的访问权限,规则本身保留。" }, managed_by: { label: "管理来源", - help: "记录来源(统一三态,A4 #2920):platform = 框架内置 / package = 应用包声明(启动时种入)/ admin = 租户在 Setup 中创建。", + help: "这条规则的来源:平台内置、随应用安装,或在此处创建。", options: { platform: "平台", package: "应用包", @@ -167,7 +167,7 @@ export const zhCNObjects: NonNullable = { }, customized: { label: "已定制", - help: "当管理员编辑应用包声明的规则时置位;此后启动种入不再覆盖该行(停用状态可跨重新部署保留)。对 admin 来源的记录无意义。" + help: "当你修改了随应用安装的规则后置位,应用更新时你的改动会被保留。" }, created_at: { label: "创建时间"