@@ -170,6 +170,140 @@ describe('explainAccess (ADR-0090 D6)', () => {
170170 } ) ;
171171} ) ;
172172
173+ describe ( 'explainAccess — record-grained (C2 / ADR-0095)' , ( ) => {
174+ const REC_CTX = { userId : 'u1' , tenantId : 'org1' , positions : [ 'sales_rep' , 'everyone' ] , permissions : [ ] } ;
175+
176+ function recDeps ( opts : {
177+ layered ?: { layer0 : any ; layer1 : any } ;
178+ record ?: Record < string , unknown > | null ;
179+ sharingFilter ?: unknown ;
180+ shares ?: any [ ] ;
181+ canEdit ?: boolean ;
182+ sets ?: any [ ] ;
183+ schema ?: any ;
184+ } = { } ) : ExplainEngineDeps {
185+ const base = makeDeps ( { sets : opts . sets , schema : opts . schema } ) ;
186+ return {
187+ ...base ,
188+ computeLayeredRlsFilter : async ( ) => opts . layered ?? { layer0 : null , layer1 : null } ,
189+ fetchRecord : async ( ) => ( opts . record !== undefined ? opts . record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u1' } ) ,
190+ sharingReadFilter : async ( ) => ( opts . sharingFilter !== undefined ? opts . sharingFilter : null ) ,
191+ listRecordShares : async ( ) => opts . shares ?? [ ] ,
192+ canEditRecord : async ( ) => opts . canEdit ?? false ,
193+ } ;
194+ }
195+
196+ it ( 'leaves the object-level report byte-identical when no recordId is supplied (backward compat)' , async ( ) => {
197+ const d = await explainAccess ( recDeps ( ) , { object : 'leave_request' , operation : 'read' , context : REC_CTX } ) ;
198+ expect ( d . record ) . toBeUndefined ( ) ;
199+ expect ( d . principal ) . not . toHaveProperty ( 'posture' ) ;
200+ expect ( d . layers . map ( ( l ) => l . layer ) ) . toEqual ( [
201+ 'principal' , 'required_permissions' , 'object_crud' , 'fls' ,
202+ 'owd_baseline' , 'depth' , 'sharing' , 'vama_bypass' , 'rls' ,
203+ ] ) ;
204+ expect ( d . layers . every ( ( l ) => l . kernelTier === undefined ) ) . toBe ( true ) ;
205+ expect ( d . layers . every ( ( l ) => l . record === undefined ) ) . toBe ( true ) ;
206+ } ) ;
207+
208+ it ( 'prepends the tenant_isolation Layer 0, tags every layer with kernelTier, and resolves posture' , async ( ) => {
209+ const d = await explainAccess (
210+ recDeps ( { layered : { layer0 : { organization_id : 'org1' } , layer1 : null } , record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u1' } } ) ,
211+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ,
212+ ) ;
213+ expect ( d . layers [ 0 ] . layer ) . toBe ( 'tenant_isolation' ) ;
214+ expect ( d . layers [ 0 ] . kernelTier ) . toBe ( 'layer_0_tenant' ) ;
215+ expect ( d . layers . find ( ( l ) => l . layer === 'rls' ) ! . kernelTier ) . toBe ( 'layer_1_business' ) ;
216+ expect ( d . principal . posture ) . toBe ( 'MEMBER' ) ;
217+ expect ( d . record ) . toMatchObject ( { recordId : 'r1' , visible : true } ) ;
218+ } ) ;
219+
220+ it ( 'derives PLATFORM_ADMIN posture from the platform_admin position' , async ( ) => {
221+ const d = await explainAccess (
222+ recDeps ( { sets : [ ADMIN ] , layered : { layer0 : null , layer1 : null } } ) ,
223+ { object : 'leave_request' , operation : 'read' , context : { userId : 'a1' , tenantId : 'org1' , positions : [ 'platform_admin' , 'everyone' ] , permissions : [ ] } , recordId : 'r1' } ,
224+ ) ;
225+ expect ( d . principal . posture ) . toBe ( 'PLATFORM_ADMIN' ) ;
226+ } ) ;
227+
228+ it ( 'Layer 0 (the tenant wall) excludes a cross-org record — decidedBy tenant_isolation' , async ( ) => {
229+ const d = await explainAccess (
230+ recDeps ( { layered : { layer0 : { organization_id : 'org1' } , layer1 : null } , record : { id : 'r1' , organization_id : 'org2' , owner_id : 'u1' } } ) ,
231+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ,
232+ ) ;
233+ const tenant = d . layers . find ( ( l ) => l . layer === 'tenant_isolation' ) ! ;
234+ expect ( tenant . record ! . outcome ) . toBe ( 'excluded' ) ;
235+ expect ( tenant . record ! . rules [ 0 ] ) . toMatchObject ( { kind : 'tenant_filter' , effect : 'excludes' } ) ;
236+ expect ( d . record ) . toMatchObject ( { visible : false , decidedBy : 'tenant_isolation' } ) ;
237+ } ) ;
238+
239+ it ( 'Layer 1 (business RLS) excludes a non-matching record — decidedBy rls' , async ( ) => {
240+ const d = await explainAccess (
241+ recDeps ( { layered : { layer0 : null , layer1 : { status : 'open' } } , record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u1' , status : 'closed' } } ) ,
242+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ,
243+ ) ;
244+ const rls = d . layers . find ( ( l ) => l . layer === 'rls' ) ! ;
245+ expect ( rls . record ! . outcome ) . toBe ( 'excluded' ) ;
246+ expect ( rls . record ! . matchesRecord ) . toBe ( false ) ;
247+ expect ( d . record ) . toMatchObject ( { visible : false , decidedBy : 'rls' } ) ;
248+ } ) ;
249+
250+ it ( 'a record_share admits a non-owner on a private object — sharing admits, decidedBy sharing' , async ( ) => {
251+ const d = await explainAccess (
252+ recDeps ( {
253+ layered : { layer0 : null , layer1 : null } ,
254+ record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u_other' } ,
255+ shares : [ { id : 'shr_1' , recipient_type : 'user' , recipient_id : 'u1' , access_level : 'read' , source : 'manual' } ] ,
256+ sharingFilter : { $or : [ { owner_id : 'u1' } , { id : { $in : [ 'r1' ] } } ] } ,
257+ } ) ,
258+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ,
259+ ) ;
260+ const sharing = d . layers . find ( ( l ) => l . layer === 'sharing' ) ! ;
261+ expect ( sharing . record ! . outcome ) . toBe ( 'admitted' ) ;
262+ expect ( sharing . record ! . rules [ 0 ] ) . toMatchObject ( { kind : 'record_share' , effect : 'admits' , grants : 'read' } ) ;
263+ expect ( d . record ) . toMatchObject ( { visible : true , decidedBy : 'sharing' } ) ;
264+ } ) ;
265+
266+ it ( 'private object, non-owner, no admitting share — not visible, decidedBy sharing' , async ( ) => {
267+ const d = await explainAccess (
268+ recDeps ( { layered : { layer0 : null , layer1 : null } , record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u_other' } , shares : [ ] , sharingFilter : { owner_id : 'u1' } } ) ,
269+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ,
270+ ) ;
271+ expect ( d . layers . find ( ( l ) => l . layer === 'sharing' ) ! . record ! . outcome ) . toBe ( 'excluded' ) ;
272+ expect ( d . record ) . toMatchObject ( { visible : false , decidedBy : 'sharing' } ) ;
273+ } ) ;
274+
275+ it ( 'a missing record yields not_evaluated row layers and an invisible verdict' , async ( ) => {
276+ const d = await explainAccess (
277+ recDeps ( { record : null } ) ,
278+ { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'missing' } ,
279+ ) ;
280+ expect ( d . record ) . toMatchObject ( { visible : false } ) ;
281+ expect ( d . record ! . decidedBy ) . toBeUndefined ( ) ;
282+ expect ( d . layers . find ( ( l ) => l . layer === 'rls' ) ! . record ! . outcome ) . toBe ( 'not_evaluated' ) ;
283+ expect ( d . layers . find ( ( l ) => l . layer === 'tenant_isolation' ) ! . record ! . outcome ) . toBe ( 'not_evaluated' ) ;
284+ } ) ;
285+
286+ it ( 'write ops use the sharing service canEdit as the by-construction verdict' , async ( ) => {
287+ const editor = PermissionSetSchema . parse ( { name : 'editor' , objects : { leave_request : { allowRead : true , allowEdit : true } } } ) ;
288+ const d = await explainAccess (
289+ recDeps ( { sets : [ editor ] , layered : { layer0 : null , layer1 : null } , record : { id : 'r1' , organization_id : 'org1' , owner_id : 'u_other' } , canEdit : true , shares : [ ] } ) ,
290+ { object : 'leave_request' , operation : 'update' , context : REC_CTX , recordId : 'r1' } ,
291+ ) ;
292+ expect ( d . layers . find ( ( l ) => l . layer === 'sharing' ) ! . record ! . outcome ) . toBe ( 'admitted' ) ;
293+ expect ( d . record ) . toMatchObject ( { recordId : 'r1' , visible : true } ) ;
294+ } ) ;
295+
296+ it ( 'degrades gracefully with no record-grained deps — object-level layers plus a best-effort verdict' , async ( ) => {
297+ // Only the base object-level deps: recordId is given but fetchRecord /
298+ // computeLayeredRlsFilter etc. are absent (e.g. no plugin-sharing).
299+ const d = await explainAccess ( makeDeps ( ) , { object : 'leave_request' , operation : 'read' , context : REC_CTX , recordId : 'r1' } ) ;
300+ expect ( d . layers [ 0 ] . layer ) . toBe ( 'tenant_isolation' ) ;
301+ expect ( d . record ) . toMatchObject ( { recordId : 'r1' } ) ;
302+ // record could not be fetched → not_evaluated tenant + invisible.
303+ expect ( d . layers . find ( ( l ) => l . layer === 'tenant_isolation' ) ! . record ! . outcome ) . toBe ( 'not_evaluated' ) ;
304+ } ) ;
305+ } ) ;
306+
173307describe ( 'buildContextForUser' , ( ) => {
174308 const ql = {
175309 async find ( object : string , opts : any ) {
0 commit comments