From 7adf218e53a3cf0f772d6def1f94e7649efdff90 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:55:55 +0000 Subject: [PATCH 1/2] fix(plugin-security): withdraw the sys_capability Deactivate dialog's false grant-revocation claim (#8535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deactivate_capability confirmation dialog told the admin that grants and resource requirements referencing the capability 'stop resolving until re-activated'. No code path enforces that: getSystemPermissions() unions permissionSets[].systemPermissions as plain strings and requiredPermissions is matched against that string set — neither loads a sys_capability row. The table's only two production readers are seeders, which write active: true on insert and never read it back. Per the maintainer ruling of 2026-08-13 (ADR-0049 enforce-or-remove, option B), the claim is withdrawn rather than enforced; putting the registry on the authorization hot path is an architectural change needing its own card. - reword the dialog in the source object and in all four shipped locale bundles (editing the source does not rewrite bundles — they were corrected by hand) - declare active's real semantics in a field description it never had - demote active from highlightFields, the danger variant, and the two scoped list views; keep it in the full-catalogue view where it belongs - add a sweep asserting the withdrawn claim survives on no surface in any locale Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .../sys-capability-deactivation-claim.test.ts | 174 ++++++++++++++++++ .../src/objects/sys-capability.object.ts | 59 +++++- .../src/translations/en.objects.generated.ts | 5 +- .../translations/es-ES.objects.generated.ts | 5 +- .../translations/ja-JP.objects.generated.ts | 5 +- .../translations/zh-CN.objects.generated.ts | 5 +- 6 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts diff --git a/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts new file mode 100644 index 0000000000..07f812be8b --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#8535] `sys_capability.active` claims nothing about authorization — on ANY +// shipped surface, in ANY locale. +// +// The defect this pins: `deactivate_capability`'s confirmation dialog told the +// admin "Grants and resource requirements that reference it stop resolving until +// re-activated." Nothing enforced that. `PermissionEvaluator.getSystemPermissions()` +// unions `permissionSets[].systemPermissions` — plain strings — and a resource's +// `requiredPermissions` is matched against that string set; neither loads a +// `sys_capability` row. The table's only two production readers are seeders, which +// WRITE `active: true` on insert and never read it back. So an admin was told a +// withdrawal took effect and it silently did not — the escalation is what they +// believed they had prevented. +// +// The maintainer ruled (2026-08-13) that ENFORCEMENT is not the answer: putting the +// registry on the authorization hot path is an architectural change that needs its +// own designed card. The claim is withdrawn instead. +// +// ── Why this file is a SWEEP and not a pin ────────────────────────────────── +// Asserting the new wording would be the weak test: it passes the moment the new +// sentence exists, on the one surface it names, and says nothing about the five +// other places the old sentence was ALSO shipped. This walks every string on every +// surface — the object definition and all four locale bundles — and applies two +// independent checks to each: +// +// NEGATIVE the withdrawn claim's own words, per locale, appear nowhere. Catches +// a surface that was missed, and a surface added later that copies the +// old text back in. +// POSITIVE every locale's dialog and field help actually STATE the non-effect. +// Catches the cheap non-fix — deleting the false sentence and leaving +// an admin who remembers it to infer the rest — which the negative +// check alone would pass. +// +// The negative check cannot catch an arbitrary paraphrase of the falsehood in a +// language it does not read; that is why the positive check carries equal weight. +// +// If capability lifecycle ever becomes genuinely enforceable (its own card, per the +// ruling), this file is what must change with it — the wording is only true while +// `active` is unread. + +import { describe, it, expect } from 'vitest'; +import { SysCapability } from './sys-capability.object.js'; +import { enObjects } from '../translations/en.objects.generated.js'; +import { esESObjects } from '../translations/es-ES.objects.generated.js'; +import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; +import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; + +/** Every `[path, value]` string reachable in a plain-data tree. */ +function walkStrings(node: unknown, path = ''): Array<[string, string]> { + if (typeof node === 'string') return [[path, node]]; + if (Array.isArray(node)) return node.flatMap((v, i) => walkStrings(v, `${path}[${i}]`)); + if (node && typeof node === 'object') { + return Object.entries(node as Record).flatMap(([k, v]) => + walkStrings(v, path ? `${path}.${k}` : k), + ); + } + return []; +} + +/** + * The withdrawn claim, in the exact words each locale shipped it in (taken + * verbatim from the pre-fix bundles). A surface carrying any of these is + * re-asserting that deactivation withdraws access. + */ +const WITHDRAWN_CLAIM: Record = { + en: [/stop resolving/i, /until re-activated/i], + 'es-ES': [/dejar[áa]n de resolverse/i, /hasta que se reactive/i], + 'ja-JP': [/解決されなくなります/], + 'zh-CN': [/无法解析/], +}; + +/** The honest statement each locale must actually make (not merely omit the lie). */ +const NON_EFFECT_MARKER: Record = { + en: /authorization is not affected|no authorization effect/i, + 'es-ES': /no se ve afectada|no tiene ning[úu]n efecto sobre la autorizaci[óo]n/i, + 'ja-JP': /認可には(影響しません|一切影響しません)/, + 'zh-CN': /授权(不受影响|没有任何影响)/, +}; + +const BUNDLES: Array<[string, Record]> = [ + ['en', enObjects], + ['es-ES', esESObjects], + ['ja-JP', jaJPObjects], + ['zh-CN', zhCNObjects], +]; + +describe('[#8535] sys_capability deactivation claims no authorization effect', () => { + // ── NEGATIVE sweep ──────────────────────────────────────────────────────── + it('the object definition carries the withdrawn claim on no string at all', () => { + const offenders = walkStrings(SysCapability) + .filter(([, v]) => WITHDRAWN_CLAIM.en.some((re) => re.test(v))) + .map(([p, v]) => `${p}: ${v}`); + expect( + offenders, + 'a string on SysCapability states that deactivation stops grants/requirements resolving. ' + + 'Nothing enforces that (getSystemPermissions unions permission-set strings; no row is ever ' + + 'read). Either withdraw the claim, or land enforcement as its own card first.', + ).toEqual([]); + }); + + it.each(BUNDLES)('the %s bundle carries the withdrawn claim on no string at all', (locale, bundle) => { + const patterns = WITHDRAWN_CLAIM[locale]; + expect(patterns, `no withdrawn-claim patterns registered for locale ${locale}`).toBeDefined(); + const offenders = walkStrings(bundle.sys_capability, `${locale}.sys_capability`) + .filter(([, v]) => patterns.some((re) => re.test(v))) + .map(([p, v]) => `${p}: ${v}`); + expect( + offenders, + `the ${locale} bundle still ships the withdrawn claim. Editing the source object does NOT ` + + 'rewrite shipped bundles — `check-i18n-bundles.mjs --write` reports "regenerated" and leaves ' + + 'changed leaf values untouched, so this locale must be corrected by hand.', + ).toEqual([]); + }); + + // ── POSITIVE sweep ──────────────────────────────────────────────────────── + it('the source dialog states the non-effect outright', () => { + const action: any = (SysCapability.actions ?? []).find((a: any) => a.name === 'deactivate_capability'); + expect(action, 'deactivate_capability action exists').toBeDefined(); + expect( + action.confirmText, + 'the dialog must SAY authorization is unaffected, not merely stop lying about it — an admin ' + + 'who remembers the old wording has to be told it was wrong.', + ).toMatch(NON_EFFECT_MARKER.en); + }); + + it.each(BUNDLES)('the %s dialog states the non-effect outright', (locale, bundle) => { + const confirmText = bundle.sys_capability?._actions?.deactivate_capability?.confirmText; + expect(confirmText, `${locale} bundle has a deactivate_capability confirmText`).toBeTruthy(); + expect(confirmText).toMatch(NON_EFFECT_MARKER[locale]); + }); + + it('the active field documents its own inertness at the source', () => { + const active: any = (SysCapability.fields as any).active; + expect(active, 'active field exists').toBeDefined(); + expect( + active.description, + 'the field carried NO description at all, which is how the dialog became the only place its ' + + 'meaning was stated — and that statement was false.', + ).toMatch(NON_EFFECT_MARKER.en); + }); + + it.each(BUNDLES)('the %s bundle translates the active field help', (locale, bundle) => { + const help = bundle.sys_capability?.fields?.active?.help; + expect(help, `${locale} bundle has help for the active field`).toBeTruthy(); + expect( + help, + `${locale} still carries the English fill for active.help — 'os i18n extract' seeds a NEW key ` + + 'with the default-locale string, and leaving it is how a locale ships an untranslated surface.', + ).toMatch(NON_EFFECT_MARKER[locale]); + }); + + // ── Demotion from prominence (the other half of the ruling) ──────────────── + it('active is not a highlight field', () => { + expect( + SysCapability.highlightFields ?? [], + 'record-header prominence beside scope/managed_by is itself a claim that the flag belongs to ' + + 'the authorization posture — a truthful dialog under a first-class field still says it matters.', + ).not.toContain('active'); + }); + + it.each(['platform', 'org'])('the %s list view does not surface active as a column', (view) => { + const columns: string[] = ((SysCapability.listViews as any)[view]?.columns ?? []) as string[]; + expect(columns.length, `${view} view has columns`).toBeGreaterThan(0); + expect(columns).not.toContain('active'); + }); + + it('the full-catalogue view DOES still surface active', () => { + // Deliberately asserted: hiding a flag the product still lets an admin set is + // the opposite error, not a stronger fix. A catalogue attribute stays visible + // in the catalogue view. + expect((SysCapability.listViews as any).all_capabilities?.columns ?? []).toContain('active'); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts index 84fed9311a..a67219b4cb 100644 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts @@ -41,7 +41,13 @@ export const SysCapability = ObjectSchema.create({ displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', - highlightFields: ['label', 'name', 'scope', 'managed_by', 'active'], + // [#8535] `active` is deliberately NOT highlighted. It is a catalogue flag with + // no authorization effect (see the field's own comment), and record-header + // prominence next to `scope`/`managed_by` is itself a claim that it belongs to + // the authorization posture. Demoting it is half the fix; rewording the dialog + // is the other half — a truthful dialog under a field still presented as + // first-class tells the admin the flag matters after all. + highlightFields: ['label', 'name', 'scope', 'managed_by'], actions: [ { @@ -62,26 +68,59 @@ export const SysCapability = ObjectSchema.create({ name: 'deactivate_capability', label: 'Deactivate', icon: 'circle-off', - variant: 'danger', + // [#8535] Was `danger`. A danger variant is a claim in itself — it tells the + // admin the click has consequences proportionate to a security control. This + // one writes a catalogue column and nothing else. + variant: 'secondary', mode: 'custom', locations: ['list_item', 'record_header'], type: 'api', method: 'PATCH', target: '/api/v1/data/sys_capability/{id}', bodyExtra: { active: false }, - confirmText: 'Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.', + // [#8535] This used to read: "Deactivate this capability? Grants and resource + // requirements that reference it stop resolving until re-activated." No code + // path has ever enforced that. `PermissionEvaluator.getSystemPermissions()` + // unions `permissionSets[].systemPermissions` — plain strings — and + // `requiredPermissions` is compared against that string set; neither loads a + // `sys_capability` row. The two production readers of the table are both + // seeders (`bootstrap-system-capabilities.ts`, + // `bootstrap-declared-capabilities.ts`), which WRITE `active: true` on insert + // and never read it back. + // + // The direction of that falsehood was the dangerous one: an admin withdrawing + // a capability was told in a confirmation dialog that the withdrawal took + // effect, and it silently did not — the escalation is what they believed they + // had prevented. ADR-0049 enforce-or-remove; the maintainer ruled (2026-08-13) + // that enforcement is NOT the answer here — putting the registry on the + // authorization hot path is an architectural change (caching, fail-closed + // semantics, org-authored rows influencing platform capabilities) that needs + // its own designed card if capability lifecycle management ever earns real + // pull. So the claim is withdrawn instead, and the dialog now states the + // non-effect explicitly rather than merely omitting the promise: an admin who + // remembers the old wording has to be told it was wrong, not left to infer it. + confirmText: + 'Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.', successMessage: 'Capability deactivated', refreshAfter: true, }, ], + // [#8535] `active` was a column in ALL THREE views. It is now shown only in + // `all_capabilities` — the full-catalogue view, where a catalogue attribute + // genuinely belongs and stays observable and filterable for the admin who sets + // it. The two SCOPED views (`platform`, `org`) are the ones an admin works in + // to reason about who can do what, and a column sitting next to `managed_by` + // there reads as part of the authorization posture. Dropping it from all three + // was rejected as the opposite error: a flag the product lets you set but never + // lets you see is its own kind of dishonest surface. listViews: { platform: { type: 'grid', name: 'platform', label: 'Platform', data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by', 'active'], + columns: ['label', 'name', 'managed_by'], filter: [{ field: 'scope', operator: 'equals', value: 'platform' }], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, @@ -91,7 +130,7 @@ export const SysCapability = ObjectSchema.create({ name: 'org', label: 'Organization', data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by', 'active'], + columns: ['label', 'name', 'managed_by'], filter: [{ field: 'scope', operator: 'equals', value: 'org' }], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, @@ -173,9 +212,19 @@ export const SysCapability = ObjectSchema.create({ }), // ── Status ─────────────────────────────────────────────────── + // [#8535] Catalogue flag, NOT an enforcement switch. It carried no + // `description` at all, which is how the `deactivate_capability` dialog + // became the only place its meaning was stated — and that statement was + // false. The semantics are declared here now, negative half included, so the + // field documents its own inertness at the point an author or an admin meets + // it. If capability lifecycle ever becomes enforceable it arrives as a + // designed feature with its own card (maintainer ruling, 2026-08-13), and + // this comment plus the description are what must change with it. active: Field.boolean({ label: 'Active', defaultValue: true, + description: + 'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.', group: 'Status', }), diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index cd2a0e1023..1802b183d2 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -139,7 +139,8 @@ export const enObjects: NonNullable = { help: "Package that ships this capability (absent = platform-curated or admin-created)." }, active: { - label: "Active" + label: "Active", + help: "Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing." }, id: { label: "Capability ID" @@ -169,7 +170,7 @@ export const enObjects: NonNullable = { }, deactivate_capability: { label: "Deactivate", - confirmText: "Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.", + confirmText: "Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.", successMessage: "Capability deactivated" } } diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index ee1891744a..9cf0076fe5 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -139,7 +139,8 @@ export const esESObjects: NonNullable = { help: "Paquete que distribuye esta capacidad (ausente = curada por la plataforma o creada por un administrador)." }, active: { - label: "Activo" + label: "Activo", + help: "Indicador de catálogo/visibilidad para filtrar y revisar. NO tiene ningún efecto sobre la autorización: las concesiones de los conjuntos de permisos y los requiredPermissions de los recursos comparan los nombres de capacidad como cadenas y nunca leen esta fila, por lo que desmarcarlo no revoca nada." }, id: { label: "ID de capacidad" @@ -169,7 +170,7 @@ export const esESObjects: NonNullable = { }, deactivate_capability: { label: "Desactivar", - confirmText: "¿Desactivar esta capacidad? Las concesiones y los requisitos de recursos que la referencian dejarán de resolverse hasta que se reactive.", + confirmText: "¿Desactivar esta capacidad? Es solo un indicador de catálogo: marca la fila como inactiva para filtrar y revisar en Setup. La autorización NO se ve afectada: los conjuntos de permisos que la conceden, y los recursos que la requieren, la comparan por nombre y se siguen resolviendo exactamente igual que antes.", successMessage: "Capacidad desactivada" } } diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index 40a0deb500..40348de3e6 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -139,7 +139,8 @@ export const jaJPObjects: NonNullable = { help: "このケーパビリティを提供するパッケージ(未設定はプラットフォーム標準または管理者作成)。" }, active: { - label: "有効" + label: "有効", + help: "絞り込みと確認のためのカタログ/表示用フラグです。認可には一切影響しません。権限セットの付与もリソースの requiredPermissions も、ケーパビリティ名を文字列として照合し、この行を読むことはないため、これを解除しても何も取り消されません。" }, id: { label: "ケーパビリティ ID" @@ -169,7 +170,7 @@ export const jaJPObjects: NonNullable = { }, deactivate_capability: { label: "無効化", - confirmText: "このケーパビリティを無効化しますか?これを参照する付与とリソース要件は、再度有効化するまで解決されなくなります。", + confirmText: "このケーパビリティを無効化しますか?これはカタログ上のフラグにすぎず、Setup での絞り込みと確認に使われます。認可には影響しません。このケーパビリティを付与する権限セットも、これを要求するリソースも、名前で照合されるため、これまでどおり解決され続けます。", successMessage: "ケーパビリティを無効化しました" } } diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 772b28189c..9ffe5ddeaa 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -139,7 +139,8 @@ export const zhCNObjects: NonNullable = { help: "发布该能力的应用包(为空表示平台内置或管理员创建)。" }, active: { - label: "启用" + label: "启用", + help: "目录/可见性标记,用于筛选和查看。它对授权没有任何影响:权限集的授予与资源的 requiredPermissions 都按字符串匹配能力名称,从不读取该行,因此清除它不会撤销任何权限。" }, id: { label: "能力 ID" @@ -169,7 +170,7 @@ export const zhCNObjects: NonNullable = { }, deactivate_capability: { label: "停用", - confirmText: "停用该能力?引用它的授权和资源要求将无法解析,直到重新启用。", + confirmText: "停用该能力?这只是目录标记,用于在 Setup 中筛选和查看。授权不受影响——授予该能力的权限集,以及要求该能力的资源,都按名称匹配,仍会照常解析。", successMessage: "已停用能力" } } From a44649b1b9dee4efcb77e71f5b8444bf3b132924 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:04:31 +0000 Subject: [PATCH 2/2] docs(changeset): withdraw the sys_capability Deactivate promise (#8535) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/sys-capability-deactivate-claim.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .changeset/sys-capability-deactivate-claim.md diff --git a/.changeset/sys-capability-deactivate-claim.md b/.changeset/sys-capability-deactivate-claim.md new file mode 100644 index 0000000000..3def6ef6f1 --- /dev/null +++ b/.changeset/sys-capability-deactivate-claim.md @@ -0,0 +1,56 @@ +--- +"@objectstack/plugin-security": patch +--- + +fix(plugin-security): withdraw the `sys_capability` Deactivate dialog's false promise that deactivation revokes access (#8535) + +**A shipped confirmation dialog's promise is being withdrawn.** The +`deactivate_capability` action told the admin, verbatim: + +> Deactivate this capability? Grants and resource requirements that reference it +> stop resolving until re-activated. + +No code path has ever enforced that. `PermissionEvaluator.getSystemPermissions()` +unions `permissionSets[].systemPermissions` — plain strings — and a resource's +`requiredPermissions` is matched against that string set. Neither loads a +`sys_capability` row. The table's only two production readers are the seeders +(`bootstrap-system-capabilities.ts`, `bootstrap-declared-capabilities.ts`), which +**write** `active: true` on insert and never read it back. + +**What `active` actually means now, stated plainly:** it is a catalogue / +visibility flag. It marks a row inactive for filtering and review in Setup, and it +has **no authorization effect whatsoever**. Deactivating a capability revokes +nothing — permission sets that grant it and resources that require it match it by +name and keep resolving exactly as before. + +The direction of the old falsehood was the dangerous one. An admin withdrawing a +capability was told the withdrawal took effect, and it silently did not — the +escalation is what they believed they had prevented. This is ADR-0049 +enforce-or-remove; per the maintainer ruling of 2026-08-13 the claim is +**withdrawn, not enforced**. Putting the capability registry on the authorization +hot path is an architectural change — caching, fail-closed semantics, org-authored +rows influencing platform capabilities — that must arrive as a designed feature +with its own card if capability lifecycle management ever earns real pull, not as +a side effect of wiring up one field. + +Changes, all presentation and text — no behaviour changes, because there was no +behaviour to change: + +- the confirmation dialog now states the non-effect outright rather than merely + omitting the promise: an admin who remembers the old wording has to be told it + was wrong, not left to infer it; +- the same correction is made in **all four shipped locales** (`en`, `es-ES`, + `ja-JP`, `zh-CN`). Editing the source object does **not** rewrite shipped + bundles — the extractor preserves existing leaf values, so a changed string + stays stale in every locale until corrected by hand; +- `active` gains a `description` it never had, declaring its real semantics + including the negative half. Its absence is how the dialog became the only place + the field's meaning was stated — and that statement was false; +- `active` is demoted from `highlightFields`, from the `danger` action variant, + and from the two scoped list views, and stays in the full-catalogue view where a + catalogue attribute belongs. A truthful dialog under a field still presented as + first-class tells the admin the flag matters after all. + +Admins who deactivated a capability expecting access to stop should be aware that +access never stopped, and should withdraw the grant itself (the permission set's +`systemPermissions`) instead.