Skip to content

Commit e723ac5

Browse files
os-zhuangclaude
andauthored
test(spec): field-level contract lock for explain / access-matrix schemas (#2844)
api-surface.json guards the export NAMES; this locks the FIELD SHAPE that downstream consumers depend on — notably the ADR-0091 L3 enterprise product (cloud security-enterprise: recert review UX, evidence export, break-glass attribution) which reads ExplainDecision.layers[].contributors[] and AccessMatrixEntry directly. Locks: the 7 operations, the 9-layer pipeline, the 6 verdicts, contributor kind + the ADR-0091 D2 lifecycle 'state' member, the ExplainDecision principal shape (nullable userId, positions/permissionSets defaults, principalKind, onBehalfOf), and AccessMatrixEntry's required crud/bypass bits + scopes + version literal. Any break is now a deliberate, reviewable protocol change. Test-only; empty changeset (no release). Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H Co-authored-by: Claude <noreply@anthropic.com>
1 parent d79ca07 commit e723ac5

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
test(spec): field-level contract lock for the public explain / access-matrix schemas.
5+
6+
`api-surface.json` already guards the export NAMES; `explain.test.ts` now guards
7+
the FIELD SHAPE (layer/verdict/operation enums, contributor kind + ADR-0091 D2
8+
lifecycle `state`, the ExplainDecision principal shape, and AccessMatrixEntry's
9+
required crud/bypass bits + scopes). This makes explain a stable contract the
10+
ADR-0091 L3 enterprise consumer (cloud recert UX / evidence export / break-glass
11+
attribution) can depend on without drift fear. Test-only — no runtime change,
12+
no release.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Contract lock for the public explain / access-matrix schemas (ADR-0090 D6).
5+
*
6+
* These schemas are the wire contract that downstream consumers depend on —
7+
* notably the ADR-0091 **L3** enterprise product (cloud `security-enterprise`:
8+
* recertification review UX, evidence export, break-glass attribution) reads
9+
* `ExplainDecision.layers[].contributors[]` and `AccessMatrixEntry` directly.
10+
*
11+
* `api-surface.json` already locks the export NAMES (a removed export fails the
12+
* lint gate); this file locks the FIELD SHAPE (a removed/renamed field, a
13+
* dropped enum member, or a changed default). Together they make explain a
14+
* stable contract cloud can consume without drift fear. Any break here is a
15+
* deliberate, reviewable protocol change — bump the protocol major with it.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import {
20+
ExplainOperationSchema,
21+
ExplainLayerSchema,
22+
ExplainRequestSchema,
23+
ExplainDecisionSchema,
24+
AccessMatrixEntrySchema,
25+
AccessMatrixSchema,
26+
} from './explain.zod';
27+
28+
describe('ExplainOperationSchema — the operation vocabulary is fixed', () => {
29+
it('accepts exactly the seven CRUD + lifecycle operations', () => {
30+
for (const op of ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge']) {
31+
expect(ExplainOperationSchema.parse(op)).toBe(op);
32+
}
33+
});
34+
it('rejects an unknown operation', () => {
35+
expect(() => ExplainOperationSchema.parse('list')).toThrow();
36+
});
37+
});
38+
39+
describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', () => {
40+
const LAYERS = [
41+
'principal', 'required_permissions', 'object_crud', 'fls',
42+
'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls',
43+
];
44+
it('locks the nine layer ids, in order', () => {
45+
for (const layer of LAYERS) {
46+
expect(ExplainLayerSchema.parse({ layer, verdict: 'neutral', detail: 'x' }).layer).toBe(layer);
47+
}
48+
expect(() => ExplainLayerSchema.parse({ layer: 'tenant', verdict: 'neutral', detail: 'x' })).toThrow();
49+
});
50+
51+
it('locks the six verdicts', () => {
52+
for (const verdict of ['grants', 'denies', 'narrows', 'widens', 'neutral', 'not_applicable']) {
53+
expect(ExplainLayerSchema.parse({ layer: 'rls', verdict, detail: 'x' }).verdict).toBe(verdict);
54+
}
55+
expect(() => ExplainLayerSchema.parse({ layer: 'rls', verdict: 'allows', detail: 'x' })).toThrow();
56+
});
57+
58+
it('contributors default to [] and carry kind / name / via / state', () => {
59+
const bare = ExplainLayerSchema.parse({ layer: 'principal', verdict: 'neutral', detail: 'x' });
60+
expect(bare.contributors).toEqual([]);
61+
62+
const full = ExplainLayerSchema.parse({
63+
layer: 'principal',
64+
verdict: 'neutral',
65+
detail: 'x',
66+
contributors: [
67+
{ kind: 'position', name: 'approver', via: 'delegation from u_boss until 2026-08-01', state: 'active' },
68+
{ kind: 'permission_set', name: 'approve_set', via: 'position:approver' },
69+
{ kind: 'position', name: 'payroll_approver', via: 'held until 2026-07-01 — expired', state: 'expired' },
70+
{ kind: 'system', name: 'platform_admin' },
71+
],
72+
});
73+
expect(full.contributors.map((c) => c.kind)).toEqual(['position', 'permission_set', 'position', 'system']);
74+
// [ADR-0091 D2] the lifecycle state member L3 reads for the "expired" report
75+
expect(full.contributors[2].state).toBe('expired');
76+
expect(full.contributors[1].state).toBeUndefined();
77+
});
78+
79+
it('rejects an unknown contributor kind or lifecycle state', () => {
80+
expect(() => ExplainLayerSchema.parse({
81+
layer: 'principal', verdict: 'neutral', detail: 'x',
82+
contributors: [{ kind: 'role', name: 'x' }],
83+
})).toThrow();
84+
expect(() => ExplainLayerSchema.parse({
85+
layer: 'principal', verdict: 'neutral', detail: 'x',
86+
contributors: [{ kind: 'position', name: 'x', state: 'suspended' }],
87+
})).toThrow();
88+
});
89+
});
90+
91+
describe('ExplainRequestSchema — the request contract', () => {
92+
it('requires object + operation; userId optional', () => {
93+
expect(ExplainRequestSchema.parse({ object: 'leave_request', operation: 'read' })).toMatchObject({
94+
object: 'leave_request', operation: 'read',
95+
});
96+
expect(ExplainRequestSchema.parse({ object: 'x', operation: 'update', userId: 'u2' }).userId).toBe('u2');
97+
expect(() => ExplainRequestSchema.parse({ operation: 'read' })).toThrow();
98+
});
99+
});
100+
101+
describe('ExplainDecisionSchema — the full decision report L3 consumes', () => {
102+
it('round-trips a representative decision with every field L3 reads', () => {
103+
const decision = {
104+
allowed: true,
105+
object: 'leave_request',
106+
operation: 'read',
107+
principal: {
108+
userId: 'u2',
109+
positions: ['approver', 'everyone'],
110+
permissionSets: ['approve_set', 'member_default'],
111+
principalKind: 'human',
112+
onBehalfOf: { userId: 'u9' },
113+
},
114+
layers: [
115+
{
116+
layer: 'principal', verdict: 'neutral', detail: '…',
117+
contributors: [{ kind: 'position', name: 'approver', via: 'delegation from u_boss until 2026-08-01', state: 'active' }],
118+
},
119+
{ layer: 'rls', verdict: 'narrows', detail: '…', contributors: [] },
120+
],
121+
readFilter: { owner: 'u2' },
122+
};
123+
const parsed = ExplainDecisionSchema.parse(decision);
124+
expect(parsed.allowed).toBe(true);
125+
expect(parsed.principal.userId).toBe('u2');
126+
expect(parsed.principal.positions).toContain('approver');
127+
expect(parsed.principal.permissionSets).toContain('approve_set');
128+
expect(parsed.principal.principalKind).toBe('human');
129+
expect(parsed.principal.onBehalfOf).toEqual({ userId: 'u9' });
130+
expect(parsed.layers).toHaveLength(2);
131+
expect(parsed.readFilter).toEqual({ owner: 'u2' });
132+
});
133+
134+
it('principal.userId is nullable (anonymous), positions/permissionSets default to []', () => {
135+
const parsed = ExplainDecisionSchema.parse({
136+
allowed: false, object: 'x', operation: 'read',
137+
principal: { userId: null },
138+
layers: [],
139+
});
140+
expect(parsed.principal.userId).toBeNull();
141+
expect(parsed.principal.positions).toEqual([]);
142+
expect(parsed.principal.permissionSets).toEqual([]);
143+
});
144+
145+
it('rejects an unknown principalKind', () => {
146+
expect(() => ExplainDecisionSchema.parse({
147+
allowed: true, object: 'x', operation: 'read',
148+
principal: { userId: 'u', principalKind: 'robot' }, layers: [],
149+
})).toThrow();
150+
});
151+
});
152+
153+
describe('AccessMatrix schemas — the authoring-time companion', () => {
154+
it('AccessMatrixEntry locks the crud bits + super-user bypass + scopes + sharingModel', () => {
155+
const entry = AccessMatrixEntrySchema.parse({
156+
permissionSet: 'crm_admin', object: 'crm_lead',
157+
create: true, read: true, edit: true, delete: false,
158+
viewAllRecords: true, modifyAllRecords: false,
159+
readScope: 'unit_and_below', writeScope: 'own', sharingModel: 'private',
160+
});
161+
expect(entry).toMatchObject({
162+
permissionSet: 'crm_admin', object: 'crm_lead',
163+
create: true, read: true, edit: true, delete: false,
164+
viewAllRecords: true, modifyAllRecords: false,
165+
readScope: 'unit_and_below', writeScope: 'own', sharingModel: 'private',
166+
});
167+
});
168+
169+
it('the crud + bypass bits are REQUIRED (a missing bit is a contract break)', () => {
170+
expect(() => AccessMatrixEntrySchema.parse({
171+
permissionSet: 'x', object: 'y', create: true, read: true, edit: true, delete: true,
172+
viewAllRecords: true, /* modifyAllRecords missing */
173+
})).toThrow();
174+
});
175+
176+
it('AccessMatrix defaults version=1 and entries=[]', () => {
177+
expect(AccessMatrixSchema.parse({})).toEqual({ version: 1, entries: [] });
178+
expect(() => AccessMatrixSchema.parse({ version: 2 })).toThrow();
179+
});
180+
});

0 commit comments

Comments
 (0)