99 */
1010
1111import { describe , it , expect } from 'vitest' ;
12+ import { PermissionSetSchema } from '@objectstack/spec/security' ;
1213import {
1314 permissionSetRowFields ,
1415 permissionSetBodyFromRow ,
16+ permissionSpecBodyKeys ,
17+ pickRowStateColumns ,
1518 mergeRowPatchIntoBody ,
1619 recordDiffersFromBody ,
1720 upsertEnvPermissionSet ,
@@ -81,6 +84,21 @@ function makeProtocol(ql: any, declared: Record<string, any> = {}) {
8184 projector = fn ;
8285 } ,
8386 async saveMetaItem ( req : { type : string ; name : string ; item : any ; actor ?: string } ) {
87+ // [#4669] The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs
88+ // it (metadata-protocol/src/protocol.ts → `resolveOverlaySchema`), same
89+ // `[invalid_metadata]` 422 envelope. Without this the mock accepts any
90+ // object and the suite stays green while every real backfill fails —
91+ // which is how a 100%-failing projection shipped.
92+ const parsed = PermissionSetSchema . safeParse ( req . item ) ;
93+ if ( ! parsed . success ) {
94+ const summary = parsed . error . issues
95+ . map ( ( i : any ) => `${ i . path . join ( '.' ) || '<root>' } : ${ i . message } ` )
96+ . join ( '; ' ) ;
97+ const err : any = new Error ( `[invalid_metadata] permission/${ req . name } failed spec validation: ${ summary } ` ) ;
98+ err . code = 'INVALID_METADATA' ;
99+ err . status = 422 ;
100+ throw err ;
101+ }
84102 const existing = overlayFor ( req . name ) ;
85103 if ( existing ) existing . metadata = JSON . stringify ( req . item ) ;
86104 else {
@@ -157,12 +175,83 @@ describe('permissionSetBodyFromRow / permissionSetRowFields (round-trip)', () =>
157175 expect ( body . rowLevelSecurity [ 0 ] . using ) . toBe ( 'org == current_user.org' ) ;
158176 expect ( body . tabPermissions ) . toEqual ( { crm_leads : 'visible' } ) ;
159177 expect ( body . adminScope . businessUnit ) . toBe ( 'Sales' ) ;
160- expect ( body . active ) . toBe ( true ) ;
161178 // and projecting the rebuilt body changes nothing
162179 expect ( recordDiffersFromBody ( row , body ) ) . toBe ( false ) ;
163180 } ) ;
164181} ) ;
165182
183+ // ── The definition ⊆ spec contract (#4669) ─────────────────────────────────
184+ //
185+ // `sys_permission_set` carries columns the DEFINITION does not (`active`, the
186+ // timestamps, the provenance trio). Feeding them to `saveMetaItem` is what
187+ // #4001's `.strict()` schema rejects, and what took the ADR-0094 D4 backfill
188+ // to a 100% failure rate.
189+
190+ describe ( 'row→body projection keeps ONLY spec-declared keys (#4669)' , ( ) => {
191+ const legacyRow = ( ) => ( {
192+ id : 'ps_1' ,
193+ name : 'organization_admin' ,
194+ ...permissionSetRowFields ( envBody ( ) ) ,
195+ // every storage column a real row carries…
196+ active : true ,
197+ managed_by : 'admin' ,
198+ package_id : null ,
199+ customized : false ,
200+ created_at : '2026-01-01T00:00:00Z' ,
201+ updated_at : '2026-02-02T00:00:00Z' ,
202+ // …plus a column this code has never heard of
203+ some_future_column : 'whatever' ,
204+ } ) ;
205+
206+ it ( 'the whitelist is DERIVED from the spec schema, not transcribed' , ( ) => {
207+ const keys = permissionSpecBodyKeys ( ) ;
208+ // identical to PermissionSetSchema's own shape — the single source
209+ expect ( [ ...keys ] . sort ( ) ) . toEqual ( Object . keys ( ( PermissionSetSchema as any ) . shape ) . sort ( ) ) ;
210+ expect ( keys . has ( 'objects' ) ) . toBe ( true ) ;
211+ expect ( keys . has ( 'systemPermissions' ) ) . toBe ( true ) ;
212+ expect ( keys . has ( 'adminScope' ) ) . toBe ( true ) ;
213+ // `active` is a TABLE column, never a spec key — that is the whole bug
214+ expect ( keys . has ( 'active' ) ) . toBe ( false ) ;
215+ } ) ;
216+
217+ it ( 'drops `active` and every other storage column from the projected body' , ( ) => {
218+ const body = permissionSetBodyFromRow ( legacyRow ( ) ) ;
219+ for ( const col of [ 'active' , 'managed_by' , 'package_id' , 'customized' , 'created_at' , 'updated_at' , 'id' , 'some_future_column' ] ) {
220+ expect ( body , `storage column '${ col } ' must not enter the metadata body` ) . not . toHaveProperty ( col ) ;
221+ }
222+ // the definition itself survives intact
223+ expect ( body . objects ) . toEqual ( envBody ( ) . objects ) ;
224+ expect ( body . systemPermissions ) . toEqual ( envBody ( ) . systemPermissions ) ;
225+ } ) ;
226+
227+ it ( 'every key the projection emits is one the spec ACCEPTS (parsed by the real schema)' , ( ) => {
228+ const parsed = PermissionSetSchema . safeParse ( permissionSetBodyFromRow ( legacyRow ( ) ) ) ;
229+ expect ( parsed . success , parsed . success ? '' : JSON . stringify ( parsed . error . issues ) ) . toBe ( true ) ;
230+ // …and the reverse guard: a spec RENAME must fail here rather than silently
231+ // dropping the value at runtime.
232+ const keys = permissionSpecBodyKeys ( ) ;
233+ for ( const key of Object . keys ( permissionSetBodyFromRow ( legacyRow ( ) ) ) ) {
234+ expect ( keys . has ( key ) , `body key '${ key } ' is not declared by PermissionSetSchema` ) . toBe ( true ) ;
235+ }
236+ } ) ;
237+
238+ it ( 'filters a body STORED before #4001 (data at rest can still carry `active`)' , ( ) => {
239+ // a legacy sys_metadata overlay written while the schema still stripped it
240+ const legacyStored = { ...envBody ( ) , active : false , _packageId : 'com.x' } ;
241+ const merged = mergeRowPatchIntoBody ( legacyStored , { label : 'Renamed' } ) ;
242+ expect ( merged ) . not . toHaveProperty ( 'active' ) ;
243+ expect ( merged ) . not . toHaveProperty ( '_packageId' ) ;
244+ expect ( PermissionSetSchema . safeParse ( merged ) . success ) . toBe ( true ) ;
245+ } ) ;
246+
247+ it ( 'pickRowStateColumns isolates the record-state columns (normalized)' , ( ) => {
248+ expect ( pickRowStateColumns ( { active : 'false' , label : 'x' } ) ) . toEqual ( { active : false } ) ;
249+ expect ( pickRowStateColumns ( { active : true } ) ) . toEqual ( { active : true } ) ;
250+ expect ( pickRowStateColumns ( { label : 'x' } ) ) . toBeNull ( ) ;
251+ expect ( pickRowStateColumns ( null ) ) . toBeNull ( ) ;
252+ } ) ;
253+ } ) ;
254+
166255describe ( 'upsertEnvPermissionSet (ADR-0094 — record is a pure projection)' , ( ) => {
167256 it ( 'CREATES a missing record (managed_by admin) — Studio-authored sets appear in Setup' , async ( ) => {
168257 const ql = makeQl ( ) ;
@@ -176,16 +265,28 @@ describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', ()
176265 expect ( JSON . parse ( row . object_permissions ) ) . toEqual ( envBody ( ) . objects ) ;
177266 } ) ;
178267
179- it ( 'projects all facets (and active) onto an existing env-authored row' , async ( ) => {
268+ it ( 'projects all facets onto an existing env-authored row' , async ( ) => {
180269 const ql = makeQl ( ) ;
181270 ql . permRows . push ( { id : 'ps_env' , name : 'organization_admin' , managed_by : 'user' , system_permissions : '[]' , active : true } ) ;
182- const r = await upsertEnvPermissionSet ( ql , envBody ( { active : false } ) ) ;
271+ const r = await upsertEnvPermissionSet ( ql , envBody ( ) ) ;
183272 expect ( r . updated ) . toBe ( 1 ) ;
184273 const row = ql . permRows [ 0 ] ;
185274 expect ( row . id ) . toBe ( 'ps_env' ) ; // id stable — junction FKs stay valid
186275 expect ( JSON . parse ( row . system_permissions ) ) . toEqual ( [ 'setup.access' , 'manage_org_users' ] ) ;
187276 expect ( JSON . parse ( row . admin_scope ) . businessUnit ) . toBe ( 'Sales' ) ;
188- expect ( row . active ) . toBe ( false ) ;
277+ } ) ;
278+
279+ it ( '[#4669] NEVER re-flips `active` from a body — it is row state, not definition' , async ( ) => {
280+ // A body carrying `active` can only be legacy data at rest (pre-#4001) or a
281+ // caller mistake. Projecting it would silently undo an admin's
282+ // deactivate — the record's switch is the record's own.
283+ const ql = makeQl ( ) ;
284+ ql . permRows . push ( { id : 'ps_env' , name : 'organization_admin' , managed_by : 'user' , system_permissions : '[]' , active : false } ) ;
285+ await upsertEnvPermissionSet ( ql , { ...envBody ( ) , active : true } as any ) ;
286+ expect ( ql . permRows [ 0 ] . active , 'a stale body must not re-activate a deactivated set' ) . toBe ( false ) ;
287+ // …and a record the projector CREATES starts active (column default).
288+ await upsertEnvPermissionSet ( ql , envBody ( { name : 'fresh_set' } ) ) ;
289+ expect ( ql . permRows . find ( ( r : any ) => r . name === 'fresh_set' ) ?. active ) . toBe ( true ) ;
189290 } ) ;
190291
191292 it ( 'projects onto a legacy row with ABSENT provenance (platform default)' , async ( ) => {
@@ -426,15 +527,76 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () =>
426527 // metadata is the store that changed…
427528 const overlay = JSON . parse ( ql . metaRows [ 0 ] . metadata ) ;
428529 expect ( overlay . systemPermissions ) . toEqual ( [ 'setup.access' ] ) ;
429- expect ( overlay . active ) . toBe ( false ) ;
430530 expect ( overlay . objects ) . toEqual ( envBody ( ) . objects ) ; // unmentioned facets preserved
531+ // [#4669] …but `active` rode along as a COLUMN, never as a body key: the
532+ // definition stays spec-clean while the record's switch still flips.
533+ expect ( overlay ) . not . toHaveProperty ( 'active' ) ;
431534 // …and the record followed via projection
432535 expect ( JSON . parse ( ql . permRows [ 0 ] . system_permissions ) ) . toEqual ( [ 'setup.access' ] ) ;
433536 expect ( ql . permRows [ 0 ] . active ) . toBe ( false ) ;
434537 expect ( ql . permRows [ 0 ] . id ) . toBe ( rowId ) ;
435538 expect ( opCtx . result ?. id ) . toBe ( rowId ) ;
436539 } ) ;
437540
541+ it ( '[#4669] the activate/deactivate ACTIONS write the column and nothing else' , async ( ) => {
542+ // `sys-permission-set.object.ts` ships two `type:'api'` actions that PATCH
543+ // /data/sys_permission_set/{id} with `bodyExtra: { active: true|false }`.
544+ // A row-state-only patch is not a definition write: it passes through to
545+ // the driver, mints no overlay, and touches the metadata store not at all.
546+ const ql = makeQl ( ) ;
547+ const protocol = makeProtocol ( ql ) ;
548+ registerPermissionSetProjection ( protocol , { ql } ) ;
549+ await protocol . saveMetaItem ( { type : 'permission' , name : 'organization_admin' , item : envBody ( ) } ) ;
550+ const rowId = ql . permRows [ 0 ] . id ;
551+ const savesBefore = protocol . saves . length ;
552+ const metaRowsBefore = JSON . stringify ( ql . metaRows ) ;
553+ const mw = makeMiddleware ( ql , protocol ) ;
554+
555+ for ( const active of [ false , true ] ) {
556+ const nextCalled = await run ( mw , {
557+ object : 'sys_permission_set' , operation : 'update' , context : userCtx ,
558+ data : { id : rowId , active } ,
559+ } ) ;
560+ expect ( nextCalled , 'the driver performs the column write, with its ordinary semantics' ) . toBe ( true ) ;
561+ }
562+ expect ( protocol . saves . length , 'no metadata write for a pure row-state patch' ) . toBe ( savesBefore ) ;
563+ expect ( JSON . stringify ( ql . metaRows ) ) . toBe ( metaRowsBefore ) ;
564+ } ) ;
565+
566+ it ( '[#4669] deactivating a PACKAGE-owned set mints no customization overlay' , async ( ) => {
567+ const ql = makeQl ( ) ;
568+ const declaredBody = envBody ( { name : 'crm_rep' , systemPermissions : [ 'pkg.baseline' ] } ) ;
569+ ( ql as any ) . registry = { listItems : ( t : string ) => ( t === 'permission' ? [ declaredBody ] : [ ] ) } ;
570+ const protocol = makeProtocol ( ql , { crm_rep : declaredBody } ) ;
571+ registerPermissionSetProjection ( protocol , { ql } ) ;
572+ ql . permRows . push ( { id : 'ps_pkg' , name : 'crm_rep' , managed_by : 'package' , package_id : 'com.example.crm' , system_permissions : '["pkg.baseline"]' , active : true } ) ;
573+ const mw = makeMiddleware ( ql , protocol ) ;
574+ const nextCalled = await run ( mw , {
575+ object : 'sys_permission_set' , operation : 'update' , context : userCtx , data : { id : 'ps_pkg' , active : false } ,
576+ } ) ;
577+ expect ( nextCalled ) . toBe ( true ) ;
578+ expect ( ql . metaRows . length , 'switching a packaged set off is not a customization of it' ) . toBe ( 0 ) ;
579+ expect ( ql . permRows [ 0 ] . customized ) . toBeUndefined ( ) ;
580+ } ) ;
581+
582+ it ( '[#4669] INSERT honours an explicit `active` on the record (Clone action sends one)' , async ( ) => {
583+ const ql = makeQl ( ) ;
584+ const protocol = makeProtocol ( ql ) ;
585+ registerPermissionSetProjection ( protocol , { ql } ) ;
586+ const mw = makeMiddleware ( ql , protocol ) ;
587+ const opCtx : any = {
588+ object : 'sys_permission_set' , operation : 'insert' , context : userCtx ,
589+ data : {
590+ name : 'support_agent' , label : 'Support Agent' , active : false ,
591+ object_permissions : JSON . stringify ( { ticket : { allowRead : true } } ) ,
592+ } ,
593+ } ;
594+ await run ( mw , opCtx ) ;
595+ expect ( protocol . saves [ 0 ] . item , 'the definition never carries row state' ) . not . toHaveProperty ( 'active' ) ;
596+ expect ( ql . permRows [ 0 ] . active ) . toBe ( false ) ;
597+ expect ( opCtx . result ?. active ) . toBe ( false ) ;
598+ } ) ;
599+
438600 it ( 'UPDATE that renames is rejected (the name is the metadata identity)' , async ( ) => {
439601 const ql = makeQl ( ) ;
440602 const protocol = makeProtocol ( ql ) ;
@@ -570,6 +732,7 @@ describe('reconcilePermissionSetProjection', () => {
570732 } ) ;
571733 const out = await reconcilePermissionSetProjection ( protocol , { ql } ) ;
572734 expect ( out . backfilledIntoMetadata ) . toBe ( 1 ) ;
735+ expect ( out . backfillFailed ) . toBe ( 0 ) ;
573736 expect ( ql . metaRows . length ) . toBe ( 1 ) ;
574737 const body = JSON . parse ( ql . metaRows [ 0 ] . metadata ) ;
575738 expect ( body . objects ) . toEqual ( { ticket : { allowRead : true } } ) ;
@@ -578,6 +741,83 @@ describe('reconcilePermissionSetProjection', () => {
578741 expect ( out2 . backfilledIntoMetadata ) . toBe ( 0 ) ;
579742 } ) ;
580743
744+ it ( '[#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation' , async ( ) => {
745+ // The reported symptom: every `sys_permission_set` row has an `active`
746+ // column, `permissionSetBodyFromRow` handed it to `saveMetaItem`, and
747+ // #4001's `.strict()` schema rejected all of them — a 100%-failing
748+ // backfill behind one `warn`, with `backfilledIntoMetadata` stuck at 0.
749+ const ql = makeQl ( ) ;
750+ const protocol = makeProtocol ( ql ) ; // validates with the real PermissionSetSchema
751+ ql . permRows . push ( {
752+ id : 'ps_d8' , name : 'd8_qc_user' , managed_by : 'admin' ,
753+ active : true , customized : false , package_id : null ,
754+ created_at : '2026-01-01T00:00:00Z' , updated_at : '2026-01-02T00:00:00Z' ,
755+ label : 'D8 QC User' , ...permissionSetRowFields ( envBody ( { name : 'd8_qc_user' } ) ) ,
756+ } ) ;
757+ const logs : Array < { level : string ; msg : string } > = [ ] ;
758+ const logger = {
759+ info : ( m : string ) => logs . push ( { level : 'info' , msg : m } ) ,
760+ warn : ( m : string ) => logs . push ( { level : 'warn' , msg : m } ) ,
761+ error : ( m : string ) => logs . push ( { level : 'error' , msg : m } ) ,
762+ } ;
763+ const out = await reconcilePermissionSetProjection ( protocol , { ql, logger } ) ;
764+ expect ( out . backfilledIntoMetadata ) . toBe ( 1 ) ;
765+ expect ( out . backfillFailed ) . toBe ( 0 ) ;
766+ expect ( logs . some ( ( l ) => / b a c k f i l l i n t o m e t a d a t a f a i l e d | F A I L E D / i. test ( l . msg ) ) ) . toBe ( false ) ;
767+ const stored = JSON . parse ( ql . metaRows [ 0 ] . metadata ) ;
768+ expect ( stored ) . not . toHaveProperty ( 'active' ) ;
769+ expect ( stored . name ) . toBe ( 'd8_qc_user' ) ;
770+ } ) ;
771+
772+ it ( '[#4669/#4632] a REAL backfill failure is loud: error level, counted, consequence + fix' , async ( ) => {
773+ const ql = makeQl ( ) ;
774+ const protocol = makeProtocol ( ql ) ;
775+ // Not a key problem — the stored facet JSON itself is off-contract, so no
776+ // amount of key-filtering saves it. This is the case that MUST shout.
777+ ql . permRows . push ( {
778+ id : 'ps_bad' , name : 'broken_set' , managed_by : 'admin' , active : true ,
779+ label : 'Broken Set' , object_permissions : JSON . stringify ( { ticket : { allowRead : 'yes-please' } } ) ,
780+ } ) ;
781+ ql . permRows . push ( {
782+ id : 'ps_bad2' , name : 'broken_set_2' , managed_by : 'admin' , active : true ,
783+ label : 'Broken Set 2' , object_permissions : JSON . stringify ( { ticket : { nonsense : true } } ) ,
784+ } ) ;
785+ // `error` follows the platform Logger contract: (message, error?, meta?).
786+ const logs : Array < { level : string ; msg : string ; meta ?: any ; cause ?: Error } > = [ ] ;
787+ const logger = {
788+ info : ( m : string , meta ?: any ) => logs . push ( { level : 'info' , msg : m , meta } ) ,
789+ warn : ( m : string , meta ?: any ) => logs . push ( { level : 'warn' , msg : m , meta } ) ,
790+ error : ( m : string , cause ?: Error , meta ?: any ) => logs . push ( { level : 'error' , msg : m , cause, meta } ) ,
791+ } ;
792+ const out = await reconcilePermissionSetProjection ( protocol , { ql, logger } ) ;
793+
794+ // counted in the RESULT — not only in a log line nobody reads
795+ expect ( out . backfillFailed ) . toBe ( 2 ) ;
796+ expect ( out . backfilledIntoMetadata ) . toBe ( 0 ) ;
797+ expect ( ql . metaRows . length ) . toBe ( 0 ) ;
798+
799+ // level: error, never warn/info for a durability degradation
800+ const errors = logs . filter ( ( l ) => l . level === 'error' ) ;
801+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
802+ expect ( logs . some ( ( l ) => l . level === 'warn' && / b a c k f i l l / i. test ( l . msg ) ) ) . toBe ( false ) ;
803+ // said ONCE, at the first failure — not once per failed write
804+ const firstFailure = errors [ 0 ] ! ;
805+ expect ( errors . filter ( ( l ) => / b a c k f i l l i n t o m e t a d a t a F A I L E D / . test ( l . msg ) ) . length ) . toBe ( 1 ) ;
806+ // the consequence…
807+ expect ( firstFailure . msg ) . toMatch ( / N o t h i n g w i l l l o o k b r o k e n / ) ;
808+ expect ( firstFailure . msg ) . toMatch ( / r e - p r o v i s i o n / ) ;
809+ // …and the fix
810+ expect ( firstFailure . msg ) . toMatch ( / F i x : / ) ;
811+ expect ( firstFailure . meta ?. name ) . toBe ( 'broken_set' ) ;
812+
813+ // the summary carries the failure too — an `info` "reconciled" line over a
814+ // failed backfill is the reassuring half-truth the rule exists to remove
815+ const summary = errors . at ( - 1 ) ! ;
816+ expect ( summary . msg ) . toMatch ( / 2 F A I L E D b a c k f i l l / ) ;
817+ expect ( summary . meta ?. failedNames ) . toEqual ( [ 'broken_set' , 'broken_set_2' ] ) ;
818+ expect ( logs . some ( ( l ) => l . level === 'info' && / r e c o n c i l e d / . test ( l . msg ) ) ) . toBe ( false ) ;
819+ } ) ;
820+
581821 it ( 'heals a record that drifted from an EXISTING metadata definition (metadata wins)' , async ( ) => {
582822 const ql = makeQl ( ) ;
583823 const declared = { member_default : envBody ( { name : 'member_default' , systemPermissions : [ 'declared.baseline' ] } ) } ;
0 commit comments