1818 * seeding). Platform-seeded rows are `managed_by: 'platform'` so they are not
1919 * presented as admin-deletable. Runs on `kernel:ready` alongside the other
2020 * security bootstraps.
21+ *
22+ * [#5876] The two halves have DIFFERENT authority over an existing row's
23+ * display fields, because they have different claims to authorship:
24+ * - CURATED — the platform authored `label`/`description`, and a new version
25+ * may ship new copy, so the row it finds is refreshed;
26+ * - DERIVED — there is no authored copy at all, only `humanize(name)` and
27+ * `Capability <name>.` generated from a granted string, so it refreshes
28+ * only its OWN placeholder (`managed_by:'platform'` on a non-curated name)
29+ * and never a row an admin or a package authored.
30+ * The seed loop used to refresh both alike while the comment in front of it
31+ * claimed admin edits were preserved — what #2909 T3 actually made seed-once is
32+ * `scope`, and only `scope`.
2133 */
2234
2335import { PLATFORM_CAPABILITIES , type PlatformCapability } from '@objectstack/spec/security' ;
@@ -78,13 +90,31 @@ interface SeedOptions {
7890 materializedCapabilityNames ?: Iterable < string > ;
7991}
8092
93+ /** Aggregated outcome of a back-compat capability seeding pass. */
94+ export interface CapabilitySeedResult {
95+ /** Rows inserted (curated definitions + derived placeholders). */
96+ seeded : number ;
97+ /** Rows whose platform display fields were reconciled. */
98+ updated : number ;
99+ /**
100+ * [#5876] Derived names whose existing row is authored elsewhere
101+ * (`managed_by` anything but `'platform'`), so its `label`/`description` were
102+ * left as their author wrote them. Not a degradation — the capability
103+ * resolves and the authored copy is the better one — so it is reported in the
104+ * boot summary rather than warned about (#4632).
105+ */
106+ skippedAuthored : number ;
107+ /** Definitions considered this pass (curated + derived). */
108+ total : number ;
109+ }
110+
81111export async function bootstrapSystemCapabilities (
82112 ql : any ,
83113 permissionSets : Array < { systemPermissions ?: string [ ] } > = [ ] ,
84114 options : SeedOptions = { } ,
85- ) : Promise < { seeded : number ; updated : number ; total : number } > {
115+ ) : Promise < CapabilitySeedResult > {
86116 if ( ! ql || typeof ql . find !== 'function' || typeof ql . insert !== 'function' ) {
87- return { seeded : 0 , updated : 0 , total : 0 } ;
117+ return { seeded : 0 , updated : 0 , skippedAuthored : 0 , total : 0 } ;
88118 }
89119
90120 const materialized = new Set < string > ( options . materializedCapabilityNames ?? [ ] ) ;
@@ -94,26 +124,58 @@ export async function bootstrapSystemCapabilities(
94124 // ones that already have a row, which the declared seeder owns.
95125 const byName = new Map < string , CapabilityDef > ( ) ;
96126 for ( const c of KNOWN_CAPABILITIES ) byName . set ( c . name , c ) ;
127+ // [#5876] Which names came from the DERIVED half. The two halves carry
128+ // different authority over an existing row's display fields (see the
129+ // reconcile guard below), and after this loop `byName` cannot tell them
130+ // apart on its own.
131+ const derivedNames = new Set < string > ( ) ;
97132 for ( const ps of permissionSets ) {
98133 for ( const cap of ps ?. systemPermissions ?? [ ] ) {
99134 if ( typeof cap === 'string' && cap && ! byName . has ( cap ) && ! materialized . has ( cap ) ) {
100135 byName . set ( cap , { name : cap , label : humanize ( cap ) , description : `Capability ${ cap } .` , scope : 'platform' } ) ;
136+ derivedNames . add ( cap ) ;
101137 }
102138 }
103139 }
104140
105141 let seeded = 0 ;
106142 let updated = 0 ;
143+ let skippedAuthored = 0 ;
107144 for ( const def of byName . values ( ) ) {
108145 const existing = await tryFind ( ql , 'sys_capability' , { name : def . name } , 1 ) ;
109- if ( existing [ 0 ] ?. id ) {
110- // Keep label/description fresh, but do NOT clobber admin edits — only
111- // platform-owned display fields are reconciled. `scope` is an
112- // admin-editable classification face (plain select on sys_capability),
113- // so it is seed-once: written on insert, never refreshed (#2909 T3).
114- // A curated scope change in a new platform version needs a data
115- // migration — recorded in the ADR-0094 addendum.
116- if ( await tryUpdate ( ql , 'sys_capability' , { id : existing [ 0 ] . id , label : def . label , description : def . description } ) ) {
146+ const row = existing [ 0 ] ;
147+ if ( row ?. id ) {
148+ // [#5876] Reconcile display fields only where THIS pass owns the copy.
149+ //
150+ // A DERIVED name has no authored copy to ship: `label` is `humanize(name)`
151+ // and `description` is `Capability <name>.`, both generated from the
152+ // string a permission set happened to grant. Refreshing those onto a row
153+ // somebody else authored is not reconciliation, it is overwriting an
154+ // author with a placeholder — every boot, silently. For a non-curated
155+ // name a `managed_by:'platform'` row can only be this same derivation's
156+ // placeholder from an earlier boot, so that is exactly the set of rows
157+ // the derived half may refresh; `admin` (Setup-authored), `package`
158+ // (declared by its owning package) and anything else are left alone.
159+ //
160+ // The CURATED half is unchanged: those definitions are authored by the
161+ // platform and a new version legitimately ships new copy, so a curated
162+ // name still refreshes the row it finds.
163+ //
164+ // NOTE this is the WRITE-side enforcement of the same rule
165+ // `materializedCapabilityNames` states at the CALL site (#4967 Part 1):
166+ // the caller says which names another pass already materialized, and
167+ // this guard holds even when nothing said so — an admin row for a name
168+ // no package ever declared is invisible to that list.
169+ if ( derivedNames . has ( def . name ) && row . managed_by !== 'platform' ) {
170+ skippedAuthored += 1 ;
171+ continue ;
172+ }
173+ // Keep label/description fresh from the platform's own definition.
174+ // `scope` is an admin-editable classification face (plain select on
175+ // sys_capability), so it is seed-once: written on insert, never
176+ // refreshed (#2909 T3). A curated scope change in a new platform version
177+ // needs a data migration — recorded in the ADR-0094 addendum.
178+ if ( await tryUpdate ( ql , 'sys_capability' , { id : row . id , label : def . label , description : def . description } ) ) {
117179 updated += 1 ;
118180 }
119181 } else {
@@ -129,6 +191,8 @@ export async function bootstrapSystemCapabilities(
129191 if ( created ) seeded += 1 ;
130192 }
131193 }
132- options . logger ?. info ?.( '[security] system capabilities seeded into sys_capability (ADR-0066 D1)' , { seeded, updated, total : byName . size } ) ;
133- return { seeded, updated, total : byName . size } ;
194+ options . logger ?. info ?.( '[security] system capabilities seeded into sys_capability (ADR-0066 D1)' , {
195+ seeded, updated, skippedAuthored, total : byName . size ,
196+ } ) ;
197+ return { seeded, updated, skippedAuthored, total : byName . size } ;
134198}
0 commit comments