Skip to content

Commit 1be1932

Browse files
committed
chore(gates): enforce session patch and record-copy ownership
1 parent 2135bdf commit 1be1932

3 files changed

Lines changed: 520 additions & 87 deletions

File tree

‎scripts/layering/check.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
// - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e.
2323
// an import whose source zone outranks its target zone, plus a ratchet on the
2424
// same inversion measured over TYPE-ONLY edges (R6).
25-
// - Over the DAEMON only: SessionState field ownership (R7), because the session
25+
// - Over the DAEMON and its capture-admission adapters: SessionState field ownership (R7), because the session
2626
// record is store-owned mutable state that any daemon module can write; and the terminal
2727
// concrete-platform boundary (R65), which rejects every import form into the retired
2828
// src/platforms path or a platform package.
@@ -315,6 +315,24 @@ function checkSessionStateOwnership(sources: ReadonlyMap<string, string>): Layer
315315
});
316316
continue;
317317
}
318+
const syntaxFailures: Readonly<Record<string, string>> = {
319+
'[patch-shape]':
320+
'Session updates require an explicit patch literal or inline synchronous callback returning named keys. Computed keys, spreads, getters, async callbacks and opaque patches cannot establish field ownership.',
321+
'[reentrant-patch]':
322+
'A session patch callback must not call back into the store. Read the supplied current record and return named fields synchronously.',
323+
'[whole-record-spread]':
324+
'Whole SessionState copies are allowed only inside the store or declared draft constructors. Update an existing lifetime through its field owner with a named patch.',
325+
};
326+
const syntaxFailure = syntaxFailures[write.field];
327+
if (syntaxFailure) {
328+
violations.push({
329+
rule: 'R7 session-state-ownership',
330+
file: write.file,
331+
line: write.line,
332+
message: syntaxFailure,
333+
});
334+
continue;
335+
}
318336
if (owners === undefined) {
319337
const storeOwned = STORE_OWNED_SESSION_STATE_FIELDS.has(write.field);
320338
violations.push({
@@ -324,7 +342,7 @@ function checkSessionStateOwnership(sources: ReadonlyMap<string, string>): Layer
324342
message: storeOwned
325343
? `session.${write.field} is classified store-established ` +
326344
`(STORE_OWNED_SESSION_STATE_FIELDS), meaning nothing mutates it after construction — ` +
327-
`but this is a direct write. Route it through the store, or move the field into ` +
345+
`but this is a write. Route it through the store, or move the field into ` +
328346
`SESSION_STATE_FIELD_OWNERS with this module as its owner.`
329347
: `session.${write.field} has no declared owner. SessionStore hands out the live ` +
330348
`record, so this write is durable: name the owning module in ` +
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
import {
4+
findSessionStateWrites,
5+
SESSION_STATE_FIELD_OWNERS,
6+
sessionStateWritePressure,
7+
} from './session-state.ts';
8+
9+
const OWNER = 'src/daemon/app-log-session-resource.ts';
10+
const FIELDS = ['appLog', 'appLogFailure', 'lease', 'lastPerfProfile'];
11+
function scan(source: string, file = OWNER) {
12+
return findSessionStateWrites(new Map([[file, source]]), FIELDS);
13+
}
14+
15+
test('explicit owner patches name their writes and retain direct assignment checks', () => {
16+
assert.deepEqual(
17+
scan('store.update(ref, { appLogFailure: undefined });\nsession.appLog = log;').map((w) => [
18+
w.field,
19+
w.line,
20+
]),
21+
[
22+
['appLogFailure', 1],
23+
['appLog', 2],
24+
],
25+
);
26+
assert.ok(SESSION_STATE_FIELD_OWNERS.appLogFailure!.includes(OWNER));
27+
const foreign = scan(
28+
'sessionStore.update(ref, { appLogFailure: error });',
29+
'src/daemon/handlers/probe.ts',
30+
)[0]!;
31+
assert.equal(foreign.field, 'appLogFailure');
32+
assert.ok(!SESSION_STATE_FIELD_OWNERS[foreign.field]!.includes(foreign.file));
33+
});
34+
35+
test('inline synchronous patches can derive named fields with nested value spreads', () => {
36+
for (const patch of [
37+
'(current) => ({ lease: { ...current.lease, expiresAt: 10 } })',
38+
'(current) => { const expiresAt = 10; return { lease: { ...current.lease, expiresAt } }; }',
39+
'function(current) { return { lease: { ...current.lease, expiresAt: 10 } }; }',
40+
])
41+
assert.deepEqual(
42+
scan(`store.update(ref, ${patch});`).map((w) => w.field),
43+
['lease'],
44+
patch,
45+
);
46+
});
47+
48+
for (const patch of [
49+
'{ [key]: value }',
50+
'{ ...changes }',
51+
'changes',
52+
'rebuild',
53+
'async (current) => ({ appLogFailure: undefined })',
54+
'(current) => changes',
55+
'(current) => { if (test) return changes; return { appLogFailure: undefined }; }',
56+
'{ get appLogFailure() { return error; } }',
57+
]) {
58+
test(`update rejects unattributable patch ${patch}`, () => {
59+
assert.ok(scan(`store.update(ref, ${patch});`).some((w) => w.field === '[patch-shape]'));
60+
});
61+
}
62+
63+
test('patch callbacks cannot call back into the session store', () => {
64+
const writes = scan(
65+
'store.update(ref, (current) => { store.retire(ref); return { appLogFailure: undefined }; });',
66+
);
67+
assert.ok(writes.some((w) => w.field === '[reentrant-patch]'));
68+
assert.ok(writes.some((w) => w.field === 'appLogFailure'));
69+
});
70+
71+
test('the historical app-log whole-record spread is refused and counted fairly', () => {
72+
const writes = scan(
73+
'const session = sessionStore.get(address); sessionStore.set(address, { ...session, appLogFailure: error });',
74+
);
75+
assert.deepEqual(
76+
writes.map((w) => w.field),
77+
['[whole-record-spread]', 'appLogFailure'],
78+
);
79+
});
80+
81+
test('record copies through a read alias or captured ref remain visible', () => {
82+
for (const source of [
83+
'const refreshed = params.sessionStore.get(address) ?? session; return { ...refreshed, lastPerfProfile: profile };',
84+
'const previous: SessionState = value; return { ...previous, lastPerfProfile: profile };',
85+
'return { ...ref.session, lastPerfProfile: profile };',
86+
])
87+
assert.deepEqual(
88+
scan(source).map((w) => w.field),
89+
['[whole-record-spread]', 'lastPerfProfile'],
90+
source,
91+
);
92+
});
93+
94+
test('typed store aliases enforce the same explicit patch contract', () => {
95+
assert.deepEqual(
96+
scan(
97+
'function update(storage: SessionStore) { storage.update(ref, { appLogFailure: error }); }',
98+
).map((w) => w.field),
99+
['appLogFailure'],
100+
);
101+
assert.deepEqual(
102+
scan('function update(storage: SessionStore) { storage.update(ref, changes); }').map(
103+
(w) => w.field,
104+
),
105+
['[patch-shape]'],
106+
);
107+
});
108+
109+
test('plain store and record aliases retain their owning identity', () => {
110+
assert.deepEqual(
111+
scan('const storage = sessionStore; storage.update(ref, { appLogFailure: error });').map(
112+
(w) => w.field,
113+
),
114+
['appLogFailure'],
115+
);
116+
for (const source of [
117+
'const current = ref.session; return { ...current };',
118+
'const current = session; return { ...current };',
119+
]) {
120+
assert.deepEqual(
121+
scan(source).map((w) => w.field),
122+
['[whole-record-spread]'],
123+
source,
124+
);
125+
}
126+
});
127+
128+
test('destructuring preserves store and record aliases', () => {
129+
for (const pattern of ['{ session: current }', '{ session: current = fallback }']) {
130+
assert.deepEqual(
131+
scan(`const ${pattern} = ref; return { ...current, appLogFailure: error };`).map(
132+
(w) => w.field,
133+
),
134+
['[whole-record-spread]', 'appLogFailure'],
135+
);
136+
}
137+
assert.deepEqual(
138+
scan('function copy({ session: current }: SessionRef) { return { ...current }; }').map(
139+
(w) => w.field,
140+
),
141+
['[whole-record-spread]'],
142+
);
143+
assert.deepEqual(
144+
scan('const { sessionStore: storage } = params; storage.update(ref, changes);').map(
145+
(w) => w.field,
146+
),
147+
['[patch-shape]'],
148+
);
149+
});
150+
151+
test('alias declarations and patch parameters are collected before checking writes', () => {
152+
assert.deepEqual(
153+
scan(
154+
'function patch() { const nested = storage; nested.update(ref, changes); } const storage = sessionStore;',
155+
).map((w) => w.field),
156+
['[patch-shape]'],
157+
);
158+
assert.deepEqual(
159+
scan(
160+
'store.update(ref, (current) => { const entry = current; entry.lastPerfProfile = profile; return { appLogFailure: undefined }; });',
161+
).map((w) => w.field),
162+
['appLogFailure', 'lastPerfProfile'],
163+
);
164+
});
165+
166+
test('draft exceptions cover only the declared constructors and fresh open publication', () => {
167+
for (const [file, constructor] of [
168+
['src/daemon/snapshot-session.ts', 'createSnapshotSession'],
169+
['src/daemon/handlers/record-runtime.ts', 'createRecordOnlySession'],
170+
]) {
171+
assert.deepEqual(
172+
scan(
173+
`function ${constructor}(session: SessionState) { return { ...session, appLogFailure: undefined }; }`,
174+
file,
175+
),
176+
[],
177+
);
178+
assert.ok(
179+
scan('function updateSession(session: SessionState) { return { ...session }; }', file).some(
180+
(w) => w.field === '[whole-record-spread]',
181+
),
182+
);
183+
}
184+
const open = 'src/daemon/session-lifecycle/internal/session-open-state.ts';
185+
assert.deepEqual(
186+
scan(
187+
'function publishOpenSession(session: SessionState) { return store.publish(address, { ...session }); }',
188+
open,
189+
),
190+
[],
191+
);
192+
assert.ok(
193+
scan(
194+
'function publishOpenSession(session: SessionState) { return store.update(ref, { ...session }); }',
195+
open,
196+
).some((w) => w.field === '[patch-shape]'),
197+
);
198+
assert.ok(
199+
scan(
200+
'function publishOpenSession(session: SessionState) { const next = { ...session }; return next; }',
201+
open,
202+
).some((w) => w.field === '[whole-record-spread]'),
203+
);
204+
});
205+
206+
test('the owning store can merge records, while unrelated updates and platform sessions are excluded', () => {
207+
assert.deepEqual(
208+
scan(
209+
'sessionStore.update(ref, changes); session.appLogFailure = error;',
210+
'src/daemon/session-store.ts',
211+
),
212+
[],
213+
);
214+
assert.deepEqual(scan('coordinator.update((session) => ({})); hash.update(data);'), []);
215+
assert.deepEqual(
216+
scan('return { ...session, appLogFailure: error };', 'packages/platform-apple/src/session.ts'),
217+
[],
218+
);
219+
});
220+
221+
test('pressure counts capture-kit record replacement and daemon patches with the same syntax rules', () => {
222+
const declaration =
223+
'export type SessionState = {\n appLogFailure?: Error;\n lease?: object;\n};';
224+
const baseline = new Map([
225+
['src/daemon/session-state.ts', declaration],
226+
['src/daemon/owner.ts', 'session.appLogFailure = error;'],
227+
[
228+
'packages/capture-kit/src/capture-admission/owner.ts',
229+
'const next = { ...session, appLogFailure: error };',
230+
],
231+
]);
232+
const current = new Map([
233+
['src/daemon/session-state.ts', declaration],
234+
[OWNER, 'store.update(ref, { appLogFailure: error });'],
235+
['src/daemon/invalid.ts', 'store.update(ref, changes);'],
236+
]);
237+
assert.deepEqual(sessionStateWritePressure(baseline), {
238+
writerOwnedFields: 1,
239+
ownerFileClaims: 2,
240+
});
241+
assert.deepEqual(sessionStateWritePressure(current), {
242+
writerOwnedFields: 1,
243+
ownerFileClaims: 1,
244+
});
245+
});

0 commit comments

Comments
 (0)