From c567b0e5192115177e5c4b3e243927ea44d2ad20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 22:43:20 +0200 Subject: [PATCH 1/2] chore(gates): enforce captured session state ownership --- scripts/layering/check.ts | 22 +- scripts/layering/session-state.test.ts | 436 +++++++++++++++++++ scripts/layering/session-state.ts | 571 +++++++++++++++++++++---- 3 files changed, 936 insertions(+), 93 deletions(-) create mode 100644 scripts/layering/session-state.test.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index b3512b504f..dda8227948 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -22,7 +22,7 @@ // - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e. // an import whose source zone outranks its target zone, plus a ratchet on the // same inversion measured over TYPE-ONLY edges (R6). -// - Over the DAEMON only: SessionState field ownership (R7), because the session +// - Over the DAEMON and its capture-admission adapters: SessionState field ownership (R7), because the session // record is store-owned mutable state that any daemon module can write; and the terminal // concrete-platform boundary (R65), which rejects every import form into the retired // src/platforms path or a platform package. @@ -315,6 +315,24 @@ function checkSessionStateOwnership(sources: ReadonlyMap): Layer }); continue; } + const syntaxFailures: Readonly> = { + '[patch-shape]': + '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.', + '[reentrant-patch]': + 'A session patch callback must not call back into the store. Read the supplied current record and return named fields synchronously.', + '[whole-record-spread]': + '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.', + }; + const syntaxFailure = syntaxFailures[write.field]; + if (syntaxFailure) { + violations.push({ + rule: 'R7 session-state-ownership', + file: write.file, + line: write.line, + message: syntaxFailure, + }); + continue; + } if (owners === undefined) { const storeOwned = STORE_OWNED_SESSION_STATE_FIELDS.has(write.field); violations.push({ @@ -324,7 +342,7 @@ function checkSessionStateOwnership(sources: ReadonlyMap): Layer message: storeOwned ? `session.${write.field} is classified store-established ` + `(STORE_OWNED_SESSION_STATE_FIELDS), meaning nothing mutates it after construction — ` + - `but this is a direct write. Route it through the store, or move the field into ` + + `but this is a write. Route it through the store, or move the field into ` + `SESSION_STATE_FIELD_OWNERS with this module as its owner.` : `session.${write.field} has no declared owner. SessionStore hands out the live ` + `record, so this write is durable: name the owning module in ` + diff --git a/scripts/layering/session-state.test.ts b/scripts/layering/session-state.test.ts new file mode 100644 index 0000000000..c2c12bdb12 --- /dev/null +++ b/scripts/layering/session-state.test.ts @@ -0,0 +1,436 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseSync } from 'oxc-parser'; +import { + findSessionStateWrites, + SESSION_STATE_FIELD_OWNERS, + sessionStateWritePressure, +} from './session-state.ts'; + +const OWNER = 'src/daemon/app-log-session-resource.ts'; +const FIELDS = ['appLog', 'appLogFailure', 'lease', 'lastPerfProfile']; +function scan(source: string, file = OWNER) { + assert.deepEqual(parseSync(file, source).errors, [], `fixture must parse as a module: ${file}`); + return findSessionStateWrites(new Map([[file, source]]), FIELDS); +} + +test('explicit owner patches name their writes and retain direct assignment checks', () => { + assert.deepEqual( + scan('store.update(ref, { appLogFailure: undefined });\nsession.appLog = log;').map((w) => [ + w.field, + w.line, + ]), + [ + ['appLogFailure', 1], + ['appLog', 2], + ], + ); + assert.ok(SESSION_STATE_FIELD_OWNERS.appLogFailure!.includes(OWNER)); + const foreign = scan( + 'sessionStore.update(ref, { appLogFailure: error });', + 'src/daemon/handlers/probe.ts', + )[0]!; + assert.equal(foreign.field, 'appLogFailure'); + assert.ok(!SESSION_STATE_FIELD_OWNERS[foreign.field]!.includes(foreign.file)); +}); + +test('inline synchronous patches can derive named fields with nested value spreads', () => { + for (const patch of [ + '(current) => ({ lease: { ...current.lease, expiresAt: 10 } })', + '(current) => { const expiresAt = 10; return { lease: { ...current.lease, expiresAt } }; }', + 'function(current) { return { lease: { ...current.lease, expiresAt: 10 } }; }', + ]) + assert.deepEqual( + scan(`store.update(ref, ${patch});`).map((w) => w.field), + ['lease'], + patch, + ); +}); + +for (const patch of [ + '{ [key]: value }', + '{ ...changes }', + 'changes', + 'rebuild', + 'async (current) => ({ appLogFailure: undefined })', + '(current) => changes', + '(current) => { if (test) return changes; return { appLogFailure: undefined }; }', + '{ get appLogFailure() { return error; } }', +]) { + test(`update rejects unattributable patch ${patch}`, () => { + assert.ok(scan(`store.update(ref, ${patch});`).some((w) => w.field === '[patch-shape]')); + }); +} + +test('patch callbacks cannot call back into the session store', () => { + const writes = scan( + 'store.update(ref, (current) => { store.retire(ref); return { appLogFailure: undefined }; });', + ); + assert.ok(writes.some((w) => w.field === '[reentrant-patch]')); + assert.ok(writes.some((w) => w.field === 'appLogFailure')); +}); + +test('the historical app-log whole-record spread is refused and counted fairly', () => { + const writes = scan( + 'const session = sessionStore.get(address); sessionStore.set(address, { ...session, appLogFailure: error });', + ); + assert.deepEqual( + writes.map((w) => w.field), + ['[whole-record-spread]', 'appLogFailure'], + ); +}); + +test('record copies through a read alias or captured ref remain visible', () => { + for (const source of [ + 'function copy(session: SessionState) { const refreshed = params.sessionStore.get(address) ?? session; return { ...refreshed, lastPerfProfile: profile }; }', + 'function copy(value: SessionState) { const previous: SessionState = value; return { ...previous, lastPerfProfile: profile }; }', + 'function copy(ref: SessionRef) { return { ...ref.session, lastPerfProfile: profile }; }', + ]) + assert.deepEqual( + scan(source).map((w) => w.field), + ['[whole-record-spread]', 'lastPerfProfile'], + source, + ); +}); + +test('typed store aliases enforce the same explicit patch contract', () => { + assert.deepEqual( + scan( + 'function update(storage: SessionStore) { storage.update(ref, { appLogFailure: error }); }', + ).map((w) => w.field), + ['appLogFailure'], + ); + assert.deepEqual( + scan('function update(storage: SessionStore) { storage.update(ref, changes); }').map( + (w) => w.field, + ), + ['[patch-shape]'], + ); +}); + +test('plain store and record aliases retain their owning identity', () => { + assert.deepEqual( + scan('const storage = sessionStore; storage.update(ref, { appLogFailure: error });').map( + (w) => w.field, + ), + ['appLogFailure'], + ); + for (const source of [ + 'function copy(ref: SessionRef) { const current = ref.session; return { ...current }; }', + 'function copy(session: SessionState) { const current = session; return { ...current }; }', + ]) { + assert.deepEqual( + scan(source).map((w) => w.field), + ['[whole-record-spread]'], + source, + ); + } +}); + +test('direct field writes through a tracked SessionRef session are visible', () => { + for (const source of [ + 'function mutate(ref: SessionRef) { ref.session.appLogFailure = error; }', + 'function mutate(store: SessionStore) { const ref = store.lookup(address); ref.session.appLogFailure = error; }', + 'function mutate(ref: SessionRef) { ref["session"].appLogFailure = error; }', + ]) + assert.deepEqual( + scan(source).map((write) => write.field), + ['appLogFailure'], + source, + ); + assert.deepEqual( + scan( + 'function mutate(ref: SessionRef) { const { session: current } = ref; current.appLogFailure = error; }', + ).map((write) => write.field), + ['appLogFailure'], + ); + assert.deepEqual( + scan( + 'function mutate(ref: SessionRef) { const current = ref?.session; current.appLogFailure = error; }', + ).map((write) => write.field), + ['appLogFailure'], + ); +}); + +test('unrelated session properties are not SessionState records', () => { + for (const source of [ + 'function copy(request: { session: string }) { return { ...request.session }; }', + 'function mutate(request: { session: string }) { const { session: current } = request; current.appLogFailure = error; }', + 'function copy(ref: SessionRef, session: string) { return { ...ref[session] }; }', + ]) + assert.deepEqual(scan(source), [], source); +}); + +test('optional-chain record and store reads remain visible through aliases', () => { + for (const source of [ + 'function copy(ref: SessionRef) { const current = ref?.session; return { ...current }; }', + 'function copy(ref: SessionRef) { return { ...ref?.session }; }', + 'function copy(ref: SessionRef) { return { ...ref["session"] }; }', + 'function copy(sessionStore: SessionStore, ref: SessionRef) { const current = sessionStore?.get(ref); return { ...current }; }', + ]) { + assert.deepEqual( + scan(source).map((write) => write.field), + ['[whole-record-spread]'], + source, + ); + } +}); + +test('direct clone APIs and full object-rest patterns cannot copy a session record', () => { + for (const source of [ + 'function copy(session: SessionState) { return Object.assign({}, session); }', + 'function copy(session: SessionState) { return structuredClone(session); }', + 'function copy(session: SessionState) { const { ...copy } = session; }', + ]) { + assert.deepEqual( + scan(source).map((write) => write.field), + ['[whole-record-spread]'], + source, + ); + } + assert.deepEqual( + scan( + 'function copy(session: SessionState) { const { lease, ...rest } = session; rest.appLogFailure = error; }', + ).map((write) => write.field), + ['[whole-record-spread]', 'appLogFailure'], + ); + assert.deepEqual( + scan( + 'function copy(params: object, key: string) { const { [key]: current } = params; current.appLogFailure = error; }', + ), + [], + ); + assert.deepEqual( + scan( + 'function copy(params: object, session: string) { const { [session]: current } = params; current.appLogFailure = error; }', + ), + [], + ); +}); + +test('nested callback returns do not make a direct patch return ambiguous', () => { + for (const source of [ + 'store.update(ref, (current) => { const inspect = () => { if (current) return; }; return { lease: current.lease }; });', + 'store.update(ref, (current) => { items.forEach((item) => { if (item) return; }); return { lease: { ...current.lease } }; });', + ]) { + assert.deepEqual( + scan(source).map((write) => write.field), + ['lease'], + source, + ); + } +}); + +test('rest copies retain identity from an inline patch callback current record', () => { + for (const record of ['current', 'previous']) { + const source = `store.update(ref, (current) => { + const previous = current; + const { lease, ...copy } = ${record}; + return { appLogFailure: copy.appLogFailure }; + });`; + assert.deepEqual( + scan(source).map((write) => write.field), + ['[whole-record-spread]', 'appLogFailure'], + source, + ); + } +}); + +test('destructuring preserves store and record aliases', () => { + for (const pattern of ['{ session: current }', '{ session: current = fallback }']) { + assert.deepEqual( + scan( + `function copy(ref: SessionRef) { const ${pattern} = ref; return { ...current, appLogFailure: error }; }`, + ).map((w) => w.field), + ['[whole-record-spread]', 'appLogFailure'], + ); + } + assert.deepEqual( + scan('function copy({ session: current }: SessionRef) { return { ...current }; }').map( + (w) => w.field, + ), + ['[whole-record-spread]'], + ); + assert.deepEqual( + scan('const { sessionStore: storage } = params; storage.update(ref, changes);').map( + (w) => w.field, + ), + ['[patch-shape]'], + ); +}); + +test('computed destructuring from a typed SessionRef cannot hide its session record', () => { + for (const source of [ + 'function copy(ref: SessionRef, key: string) { const { [key]: current } = ref; return { ...current }; }', + 'function copy({ [key]: current }: SessionRef) { return { ...current }; }', + ]) { + assert.deepEqual( + scan(source).map((write) => write.field), + ['[whole-record-spread]'], + source, + ); + } + assert.deepEqual( + scan( + 'function copy(params: object, key: string) { const { [key]: current } = params; return { ...current }; }', + ), + [], + ); +}); + +test('literal computed SessionRef fields survive real store and typed parameter sources', () => { + const key = 'const key = "session"; '; + const fromLookup = + 'const ref = store.lookup(address); const { [key]: current } = ref; current.appLogFailure = error;'; + assert.deepEqual( + scan(key + fromLookup).map((write) => write.field), + ['appLogFailure'], + ); + assert.deepEqual( + scan( + 'function mutate(params: { ref: SessionRef }) { const key = "session"; const { [key]: current } = params.ref; current.appLogFailure = error; }', + ).map((write) => write.field), + ['appLogFailure'], + ); +}); + +test('known SessionRef-returning store methods seed computed destructuring', () => { + for (const read of [ + 'store.lookup(address)', + 'store.publish(address, session)', + 'store.findByDevice(deviceId)', + 'store.refresh(ref)', + ]) { + assert.deepEqual( + scan( + `const result = ${read}; const key = "session"; const { [key]: current } = result; current.lease = lease;`, + ).map((write) => write.field), + ['lease'], + read, + ); + } +}); + +test('static destructuring carries typed ref properties into computed record reads', () => { + for (const pattern of ['{ ref }', "{ ['ref']: ref }", '{ ref: ref = fallback }']) { + const source = `function mutate(params: { ref: SessionRef }) { + const ${pattern} = params; + const key = 'session'; + const { [key]: current } = ref; + current.appLogFailure = error; + }`; + assert.deepEqual( + scan(source).map((write) => write.field), + ['appLogFailure'], + source, + ); + } + assert.deepEqual( + scan(`function mutate(params: { ref: object }) { + const { ref } = params; + const key = 'session'; + const { [key]: current } = ref; + current.appLogFailure = error; + }`), + [], + ); +}); + +test('alias declarations and patch parameters are collected before checking writes', () => { + assert.deepEqual( + scan( + 'function patch() { const nested = storage; nested.update(ref, changes); } const storage = sessionStore;', + ).map((w) => w.field), + ['[patch-shape]'], + ); + assert.deepEqual( + scan( + 'store.update(ref, (current) => { const entry = current; entry.lastPerfProfile = profile; return { appLogFailure: undefined }; });', + ).map((w) => w.field), + ['appLogFailure', 'lastPerfProfile'], + ); +}); + +test('draft exceptions cover only the declared constructors and fresh open publication', () => { + for (const [file, constructor] of [ + ['src/daemon/snapshot-session.ts', 'createSnapshotSession'], + ['src/daemon/handlers/record-runtime.ts', 'createRecordOnlySession'], + ]) { + assert.deepEqual( + scan( + `function ${constructor}(session: SessionState) { return { ...session, appLogFailure: undefined }; }`, + file, + ), + [], + ); + assert.ok( + scan('function updateSession(session: SessionState) { return { ...session }; }', file).some( + (w) => w.field === '[whole-record-spread]', + ), + ); + } + const open = 'src/daemon/session-lifecycle/internal/session-open-state.ts'; + assert.deepEqual( + scan( + 'function publishOpenSession(session: SessionState) { return store.publish(address, { ...session }); }', + open, + ), + [], + ); + assert.ok( + scan( + 'function publishOpenSession(session: SessionState) { return store.update(ref, { ...session }); }', + open, + ).some((w) => w.field === '[patch-shape]'), + ); + assert.ok( + scan( + 'function publishOpenSession(session: SessionState) { const next = { ...session }; return next; }', + open, + ).some((w) => w.field === '[whole-record-spread]'), + ); +}); + +test('the owning store can merge records, while unrelated updates and platform sessions are excluded', () => { + assert.deepEqual( + scan( + 'sessionStore.update(ref, changes); session.appLogFailure = error;', + 'src/daemon/session-store.ts', + ), + [], + ); + assert.deepEqual(scan('coordinator.update((session) => ({})); hash.update(data);'), []); + assert.deepEqual( + scan( + 'function copy(session: SessionState) { return { ...session, appLogFailure: error }; }', + 'packages/platform-apple/src/session.ts', + ), + [], + ); +}); + +test('pressure counts capture-kit record replacement and daemon patches with the same syntax rules', () => { + const declaration = + 'export type SessionState = {\n appLogFailure?: Error;\n lease?: object;\n};'; + const baseline = new Map([ + ['src/daemon/session-state.ts', declaration], + ['src/daemon/owner.ts', 'session.appLogFailure = error;'], + [ + 'packages/capture-kit/src/capture-admission/owner.ts', + 'const next = { ...session, appLogFailure: error };', + ], + ]); + const current = new Map([ + ['src/daemon/session-state.ts', declaration], + [OWNER, 'store.update(ref, { appLogFailure: error });'], + ['src/daemon/invalid.ts', 'store.update(ref, changes);'], + ]); + assert.deepEqual(sessionStateWritePressure(baseline), { + writerOwnedFields: 1, + ownerFileClaims: 2, + }); + assert.deepEqual(sessionStateWritePressure(current), { + writerOwnedFields: 1, + ownerFileClaims: 1, + }); +}); diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index fa44cc254a..1388abc843 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -1,43 +1,7 @@ -// Catches: a daemon module writing a SessionState field it does not own — aliasing through -// SessionStore.get()/set() lets any module mutate store-owned state, and only a full-graph -// AST walk over every assignment site (not a review of one module) can tell whose write -// it was. -// Evidence: PR #1392 (e8b779cb32) fixed a close-time script-save failure leaking the session/ -// device claim — a symptom of unowned SessionState writes; the field-owner table this file -// enforces is the durable fix. -// Cost: 262 LOC (no dedicated test file; exercised through daemon-modularity.test.ts and -// model.test.ts). -// Kill criterion: none enforced today; retire only by maintainer decision that per-field -// SessionState write ownership no longer matters. SessionStore hands out the live record -// through get()/set(), so a `session. =` from any module type-checks; no owner exists -// at the type level. -// -// R7 session-state ownership. -// -// `SessionStore.get()` hands back the live `SessionState` out of a private Map, and `set()` -// re-puts the same reference — so a `session. = …` anywhere in the daemon is a durable -// write to store-owned state, and whether it persists depends on aliasing rather than on an -// API call. That is workable while each field has an owner that keeps its invariants; it stops -// being workable the moment a field's rule is spread across modules, because nothing at the -// store boundary can check it. -// -// So the ownership is written down here and enforced. The table is not an aspiration: it is -// the set of writers that exist, so the gate's job is to stop the set from growing quietly. -// Adding a field to `SessionState` forces a deliberate owner; writing an existing field from -// a new module fails until that module is either declared an owner or, better, calls the -// owner instead. ADR 0014's ref frame is the worked example, and the one that has since been -// taken further than this table can go: its four fields moved together across two modules -// until `activateRefFrame` took the transition, and they are now a single value whose nominal -// type no other module can construct, edit, or derive from an existing frame. -// -// Detection is AST-based (`oxc-parser`, already a devDependency) rather than a line regex. A -// regex has to enumerate assignment operators, and the ones it forgets are exactly the ones -// that slip through: `??=` on an optional field is the natural way to write a default, and a -// computed `session[key] =` hides the field name entirely. The parser reports every -// assignment and update form for free, and a `session.a.b = …` sub-object write is reported -// as what it is rather than mistaken for a write to `a`. +/** SessionState field ownership for assignments, named patches and record copies. */ import { parseSync } from 'oxc-parser'; +import { memberName, memberPath, propertyName, visitAst } from './layering-ast.ts'; import path from 'node:path'; export type SessionStateWrite = { @@ -89,14 +53,32 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly = new Set([ 'actions', - 'appBundleId', - 'appLog', - 'appLogFailure', - 'audioProbe', 'createdAt', - 'device', // #2833: the request path reports session activity through `SessionStore.noteSessionActivity`, so // the only writer of this field is the store that owns the record. 'lastActivityAtMs', - 'lastPerfProfile', 'name', 'recordOnlySession', - 'perfCapture', - 'screenRecording', 'sessionScope', 'snapshotDiagnostics', - 'surface', ]); export function sessionStateFieldCount(): number { @@ -216,7 +189,7 @@ function isSessionBinding(name: string): boolean { /** A member expression being assigned to, or updated with `++`/`--`. */ type WriteTarget = { - object: string | undefined; + object: unknown; field: string | undefined; computed: boolean; offset: number; @@ -224,19 +197,18 @@ type WriteTarget = { function writeTarget(node: Record): WriteTarget | null { const type = node['type']; - const member = + const candidate = type === 'AssignmentExpression' ? (node['left'] as Record | undefined) : type === 'UpdateExpression' ? (node['argument'] as Record | undefined) : undefined; - if (!member || member['type'] !== 'MemberExpression') return null; + const member = unwrapExpression(candidate); + if (member?.['type'] !== 'MemberExpression') return null; const object = member['object'] as Record | undefined; const property = member['property'] as Record | undefined; return { - // Only a direct `.field` write is a session write; `a.b.c = …` writes into a - // sub-object and its `object` is a MemberExpression, so it has no identifier name here. - object: object?.['type'] === 'Identifier' ? (object['name'] as string) : undefined, + object, field: property?.['type'] === 'Identifier' ? (property['name'] as string) : undefined, computed: member['computed'] === true, offset: typeof member['start'] === 'number' ? member['start'] : 0, @@ -251,61 +223,477 @@ function lineOf(source: string, offset: number): number { return line; } -/** - * Every write to a declared `SessionState` field through a binding named `session`, in any - * assignment or update form. `session-store.ts` is excluded: it owns the record and may write - * anything on it. - * - * A computed write (`session[key] = …`) cannot be attributed to a field, so it is reported - * against the sentinel field name `[computed]` — which has no owner and therefore fails, - * rather than passing unnoticed. - */ +type AstNode = Record; + +function astNode(value: unknown): AstNode | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as AstNode) + : undefined; +} + +function unwrapExpression(value: unknown): AstNode | undefined { + let node = astNode(value); + while ( + node && + [ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'ParenthesizedExpression', + 'ChainExpression', + ].includes(String(node.type)) + ) { + node = astNode(node.expression); + } + return node; +} + +function isSessionStoreReceiver(value: unknown, storeBindings: ReadonlySet): boolean { + const members = memberPath(value); + return members !== undefined && storeBindings.has(members.at(-1)!); +} + +function isSessionRecord( + value: unknown, + sessionBindings: ReadonlySet, + sessionRefBindings: ReadonlySet, + sessionRefPaths: ReadonlySet, + storeBindings: ReadonlySet, +): boolean { + const node = unwrapExpression(value); + return node?.type === 'Identifier' + ? sessionBindings.has(String(node.name)) + : node?.type === 'MemberExpression' && + memberName(node) === 'session' && + (node.computed !== true || astNode(node.property)?.type === 'Literal') && + isSessionRefValue(node.object, sessionRefBindings, sessionRefPaths, storeBindings); +} + +function isSessionRead(value: unknown, storeBindings: ReadonlySet): boolean { + const node = unwrapExpression(value); + if (!node) return false; + if (node.type === 'LogicalExpression') return isSessionRead(node.left, storeBindings); + const callee = astNode(node.callee); + return ( + node.type === 'CallExpression' && + callee?.type === 'MemberExpression' && + ['get', 'requireCurrent', 'resolveCurrent'].includes(memberName(callee) ?? '') && + isSessionStoreReceiver(callee.object, storeBindings) + ); +} + +function isSessionRecordOrRead( + value: unknown, + sessionBindings: ReadonlySet, + sessionRefBindings: ReadonlySet, + sessionRefPaths: ReadonlySet, + storeBindings: ReadonlySet, +): boolean { + return ( + isSessionRecord(value, sessionBindings, sessionRefBindings, sessionRefPaths, storeBindings) || + isSessionRead(value, storeBindings) + ); +} + +function isSessionRefValue( + value: unknown, + sessionRefBindings: ReadonlySet, + sessionRefPaths: ReadonlySet, + storeBindings: ReadonlySet, +): boolean { + const node = unwrapExpression(value); + if (!node) return false; + if (node.type === 'LogicalExpression') + return ( + isSessionRefValue(node.left, sessionRefBindings, sessionRefPaths, storeBindings) || + isSessionRefValue(node.right, sessionRefBindings, sessionRefPaths, storeBindings) + ); + if (node.type === 'Identifier' && sessionRefBindings.has(String(node.name))) return true; + if (node.type === 'MemberExpression') { + const path = memberPath(node); + if (path && sessionRefPaths.has(path.join('\0'))) return true; + } + const callee = astNode(node.callee); + return ( + node.type === 'CallExpression' && + callee?.type === 'MemberExpression' && + ['lookup', 'publish', 'findByDevice', 'refresh'].includes(memberName(callee) ?? '') && + isSessionStoreReceiver(callee.object, storeBindings) + ); +} + +function hasNamedType(node: AstNode, name: string): boolean { + const annotation = astNode(astNode(node.typeAnnotation)?.typeAnnotation); + return annotation?.type === 'TSTypeReference' && propertyName(annotation.typeName) === name; +} + +function sessionRefPropertyPaths(node: AstNode): string[][] { + const paths: string[][] = []; + const collect = (type: AstNode | undefined, prefix: readonly string[]): void => { + if (type?.type !== 'TSTypeLiteral') return; + for (const member of type.members as AstNode[]) { + if (member.type !== 'TSPropertySignature') continue; + const field = propertyName(member.key); + if (!field) continue; + if (hasNamedType(member, 'SessionRef')) paths.push([...prefix, field]); + else collect(astNode(astNode(member.typeAnnotation)?.typeAnnotation), [...prefix, field]); + } + }; + collect(astNode(astNode(node.typeAnnotation)?.typeAnnotation), []); + return paths; +} + +function hasObjectRestPattern(pattern: AstNode): boolean { + const properties = pattern.properties as AstNode[] | undefined; + return properties?.some((property) => property.type === 'RestElement') === true; +} + +function collectPatchReturns(value: unknown, returns: AstNode[]): void { + if (Array.isArray(value)) { + for (const child of value) collectPatchReturns(child, returns); + return; + } + const node = astNode(value); + if (!node) return; + if (node.type === 'ReturnStatement') { + returns.push(node); + return; + } + if ( + ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes( + String(node.type), + ) + ) + return; + for (const child of Object.values(node)) collectPatchReturns(child, returns); +} + +function bindSessionRefProperties(pattern: AstNode, sessionBindings: Set): void { + for (const property of pattern.properties as AstNode[]) { + if (property.type !== 'Property') continue; + const key = astNode(property.key); + const field = propertyName(property.key); + if ( + property.computed === true + ? key?.type === 'Literal' && field !== 'session' + : field !== 'session' + ) + continue; + const value = astNode(property.value); + const binding = value?.type === 'AssignmentPattern' ? astNode(value.left) : value; + if (binding?.type === 'Identifier') sessionBindings.add(String(binding.name)); + } +} + +function patchObjects(value: unknown): AstNode[] | undefined { + const patch = unwrapExpression(value); + if (!patch) return undefined; + if (patch.type === 'ObjectExpression') return [patch]; + if ( + !['ArrowFunctionExpression', 'FunctionExpression'].includes(String(patch.type)) || + patch.async === true + ) + return undefined; + const body = unwrapExpression(patch.body); + if (body?.type === 'ObjectExpression') return [body]; + if (body?.type !== 'BlockStatement') return undefined; + const returns: AstNode[] = []; + collectPatchReturns(body, returns); + if (returns.length !== 1 || !(body.body as AstNode[]).includes(returns[0]!)) return undefined; + const result = unwrapExpression(returns[0]!.argument); + return result?.type === 'ObjectExpression' ? [result] : undefined; +} + +const SESSION_STATE_SCAN_ROOTS = ['src/daemon/', 'packages/capture-kit/src/capture-admission/']; +const SESSION_DRAFT_CONSTRUCTORS: Readonly> = { + 'src/daemon/session-lifecycle/internal/session-open-state.ts': 'publishOpenSession', + 'src/daemon/snapshot-session.ts': 'createSnapshotSession', + 'src/daemon/handlers/record-runtime.ts': 'createRecordOnlySession', +}; + +/** Assignments, named update keys and whole-record copies at the session ownership seam. */ export function findSessionStateWrites( sources: ReadonlyMap, fields: readonly string[], ): SessionStateWrite[] { const declared = new Set(fields); const writes: SessionStateWrite[] = []; - for (const [file, source] of sources) { - if (!file.startsWith('src/daemon/')) continue; + if (!SESSION_STATE_SCAN_ROOTS.some((root) => file.startsWith(root))) continue; if (path.posix.basename(file) === 'session-store.ts') continue; - - const parsed = parseSync(file, source); - const visit = (node: unknown): void => { - if (node === null || typeof node !== 'object') return; - if (Array.isArray(node)) { - for (const child of node) visit(child); + const program = parseSync(file, source).program; + const sessionBindings = new Set(); + const sessionRefBindings = new Set(); + const sessionRefPaths = new Set(); + const storeBindings = new Set(['store', 'sessionStore']); + const aliases: Array = []; + const objectDestructurings: Array = []; + const recordRestPatterns = new Map(); + const patches = new Set(); + const report = (node: AstNode, field: string): void => { + writes.push({ file, line: lineOf(source, Number(node.start ?? 0)), field }); + }; + visitAst(program, (node) => { + if (node.type === 'Identifier') { + if (isSessionBinding(String(node.name)) || hasNamedType(node, 'SessionState')) + sessionBindings.add(String(node.name)); + if (hasNamedType(node, 'SessionRef')) sessionRefBindings.add(String(node.name)); + if (hasNamedType(node, 'SessionStore')) storeBindings.add(String(node.name)); + for (const path of sessionRefPropertyPaths(node)) + sessionRefPaths.add([String(node.name), ...path].join('\0')); + } + if (node.type === 'VariableDeclarator') { + const id = astNode(node.id); + if (id?.type === 'Identifier') { + aliases.push([String(id.name), node.init]); + } + if (id?.type === 'ObjectPattern') { + objectDestructurings.push([id, node.init]); + if (hasObjectRestPattern(id)) recordRestPatterns.set(id, node.init); + } + } + if (node.type === 'ObjectPattern') { + if (hasNamedType(node, 'SessionRef')) bindSessionRefProperties(node, sessionBindings); + if (hasObjectRestPattern(node) && hasNamedType(node, 'SessionState')) + recordRestPatterns.set(node, undefined); + for (const property of node.properties as AstNode[]) { + if (property.type !== 'Property') continue; + const field = property.computed === true ? undefined : propertyName(property.key); + const value = astNode(property.value); + const binding = value?.type === 'AssignmentPattern' ? astNode(value.left) : value; + if (binding?.type !== 'Identifier') continue; + if (field === 'store' || field === 'sessionStore') + storeBindings.add(String(binding.name)); + } + } + }); + const inheritAliases = (): void => { + let added: boolean; + do { + added = false; + for (const [name, value] of aliases) { + if (!storeBindings.has(name) && isSessionStoreReceiver(value, storeBindings)) { + storeBindings.add(name); + added = true; + } + if ( + !sessionBindings.has(name) && + (isSessionRecord( + value, + sessionBindings, + sessionRefBindings, + sessionRefPaths, + storeBindings, + ) || + isSessionRead(value, storeBindings)) + ) { + sessionBindings.add(name); + added = true; + } + if ( + !sessionRefBindings.has(name) && + isSessionRefValue(value, sessionRefBindings, sessionRefPaths, storeBindings) + ) { + sessionRefBindings.add(name); + added = true; + } + } + for (const [pattern, value] of objectDestructurings) { + if (isSessionRefValue(value, sessionRefBindings, sessionRefPaths, storeBindings)) { + const bindingCount = sessionBindings.size; + bindSessionRefProperties(pattern, sessionBindings); + if (sessionBindings.size !== bindingCount) added = true; + } + const sourcePath = memberPath(unwrapExpression(value)); + if (!sourcePath) continue; + for (const property of pattern.properties as AstNode[]) { + if ( + property.type !== 'Property' || + (property.computed === true && astNode(property.key)?.type !== 'Literal') + ) + continue; + const field = propertyName(property.key); + const value = astNode(property.value); + const binding = value?.type === 'AssignmentPattern' ? astNode(value.left) : value; + if ( + field && + binding?.type === 'Identifier' && + !sessionRefBindings.has(String(binding.name)) && + sessionRefPaths.has([...sourcePath, field].join('\0')) + ) { + sessionRefBindings.add(String(binding.name)); + added = true; + } + } + } + } while (added); + }; + inheritAliases(); + visitAst(program, (node) => { + const callee = astNode(node.callee); + const args = node.arguments as unknown[] | undefined; + if ( + node.type !== 'CallExpression' || + callee?.type !== 'MemberExpression' || + memberName(callee) !== 'update' || + args?.length !== 2 || + !isSessionStoreReceiver(callee.object, storeBindings) + ) + return; + const patch = unwrapExpression(args[1]); + const objects = patchObjects(args[1]); + if (!objects) { + report(patch ?? node, '[patch-shape]'); return; } - const record = node as Record; - const target = writeTarget(record); - if (target && target.object !== undefined && isSessionBinding(target.object)) { - if (target.computed) { - writes.push({ file, line: lineOf(source, target.offset), field: '[computed]' }); - } else if (target.field !== undefined && declared.has(target.field)) { - writes.push({ file, line: lineOf(source, target.offset), field: target.field }); + if (patch?.type !== 'ObjectExpression') { + const binding = astNode((patch?.params as unknown[])?.[0]); + if (binding?.type === 'Identifier') sessionBindings.add(String(binding.name)); + visitAst(patch?.body, (inner) => { + const target = astNode(inner.callee); + if ( + inner.type === 'CallExpression' && + target?.type === 'MemberExpression' && + isSessionStoreReceiver(target.object, storeBindings) + ) + report(inner, '[reentrant-patch]'); + }); + } + for (const object of objects) { + patches.add(object); + for (const property of object.properties as AstNode[]) { + if ( + property.type !== 'Property' || + property.computed === true || + property.method === true || + property.kind !== 'init' + ) + report(property, '[patch-shape]'); + else report(property, propertyName(property.key) ?? '[patch-shape]'); + } + } + }); + inheritAliases(); + for (const [pattern, source] of recordRestPatterns) { + if ( + hasNamedType(pattern, 'SessionState') || + isSessionRecordOrRead( + source, + sessionBindings, + sessionRefBindings, + sessionRefPaths, + storeBindings, + ) + ) { + report(pattern, '[whole-record-spread]'); + for (const property of pattern.properties as AstNode[]) { + const binding = property.type === 'RestElement' ? astNode(property.argument) : undefined; + if (binding?.type === 'Identifier') sessionBindings.add(String(binding.name)); } } - for (const key of Object.keys(record)) visit(record[key]); + } + inheritAliases(); + const walk = (value: unknown, ancestors: readonly AstNode[]): void => { + if (Array.isArray(value)) { + for (const child of value) walk(child, ancestors); + return; + } + const node = astNode(value); + if (!node) return; + const target = writeTarget(node); + if ( + target && + isSessionRecord( + target.object, + sessionBindings, + sessionRefBindings, + sessionRefPaths, + storeBindings, + ) + ) { + if (target.computed) report(node, '[computed]'); + else if (target.field !== undefined && declared.has(target.field)) + report(node, target.field); + } + if (node.type === 'CallExpression') { + const callee = unwrapExpression(node.callee); + const args = node.arguments as unknown[] | undefined; + const objectAssign = + callee?.type === 'MemberExpression' && + memberName(callee) === 'assign' && + astNode(callee.object)?.type === 'Identifier' && + astNode(callee.object)?.name === 'Object'; + const structuredClone = callee?.type === 'Identifier' && callee.name === 'structuredClone'; + if ( + (objectAssign || structuredClone) && + args?.some((argument) => + isSessionRecordOrRead( + argument, + sessionBindings, + sessionRefBindings, + sessionRefPaths, + storeBindings, + ), + ) + ) + report(node, '[whole-record-spread]'); + } + if (node.type === 'ObjectExpression' && !patches.has(node)) { + const properties = node.properties as AstNode[]; + const copiesRecord = properties.some((property) => { + if (property.type !== 'SpreadElement') return false; + return isSessionRecordOrRead( + property.argument, + sessionBindings, + sessionRefBindings, + sessionRefPaths, + storeBindings, + ); + }); + if (copiesRecord) { + const enclosingFunction = [...ancestors] + .reverse() + .find((ancestor) => + ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes( + String(ancestor.type), + ), + ); + const constructor = propertyName(enclosingFunction?.id); + const parent = ancestors.at(-1); + const publishedDraft = file.endsWith('/session-open-state.ts') + ? parent?.type === 'CallExpression' && + memberName(astNode(parent.callee) ?? {}) === 'publish' && + (parent.arguments as unknown[])[1] === node + : true; + const draft = SESSION_DRAFT_CONSTRUCTORS[file]; + if (!draft || constructor !== draft || !publishedDraft) { + report(node, '[whole-record-spread]'); + for (const property of properties) { + const field = propertyName(property.key); + if (property.type === 'Property' && declared.has(field ?? '')) + report(property, field!); + } + } + } + } + for (const child of Object.values(node)) walk(child, [...ancestors, node]); }; - visit(parsed.program); + walk(program, []); } - return writes.sort( (left, right) => left.file.localeCompare(right.file) || left.line - right.line, ); } export type SessionStateWritePressure = Readonly<{ - /** Declared fields that some daemon module writes directly. */ + /** Declared fields written by an owner through assignments, patches or record copies. */ writerOwnedFields: number; /** Distinct (field, writing module) pairs — what `SESSION_STATE_FIELD_OWNERS` claims. */ ownerFileClaims: number; }>; /** - * R10's measurement of R7 pressure: how many declared fields have a direct writer, and how many + * R10 measures how many declared fields have a writer, and how many * module claims that takes. Read from the tree rather than from the ownership table, so the same * function measures a merge-base tree whose table is not in scope. On a tree R7 accepts, both * numbers equal the table's own size. @@ -315,10 +703,11 @@ export function sessionStateWritePressure( ): SessionStateWritePressure { const declarationFile = sessionStateDeclarationFile(sources); if (!declarationFile) return { writerOwnedFields: 0, ownerFileClaims: 0 }; - const writes = findSessionStateWrites( - sources, - sessionStateFields(sources.get(declarationFile)!), - ).filter((write) => write.field !== '[computed]'); + const fields = sessionStateFields(sources.get(declarationFile)!); + const declared = new Set(fields); + const writes = findSessionStateWrites(sources, fields).filter((write) => + declared.has(write.field), + ); return { writerOwnedFields: new Set(writes.map((write) => write.field)).size, ownerFileClaims: new Set(writes.map((write) => `${write.field}\0${write.file}`)).size, From 3312b9869b345ba6977f6a85742472b047a51f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:04:38 +0200 Subject: [PATCH 2/2] chore: retire standalone recording publication authority --- scripts/layering/session-resource-ownership.test.ts | 1 + scripts/layering/session-resource-ownership.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index a85922db6c..9f29c09f99 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -51,6 +51,7 @@ test('session resources are constructed only by their durable domain owners', () 'src/daemon/handlers/planted.ts: session audioProbe record constructed outside its owner', 'src/daemon/handlers/planted.ts: session perfCapture record constructed outside its owner', 'src/daemon/handlers/planted.ts: session screenRecording record constructed outside its owner', + 'src/daemon/screen-recording-session-binding.ts: session screenRecording record constructed outside its owner', 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts: session audioProbe record constructed outside its owner', 'packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts: session perfCapture record constructed outside its owner', ], diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index d0a8a61d3b..f9274f53e8 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -30,7 +30,6 @@ const RESOURCE_OWNERS: Readonly>> = { audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), screenRecording: new Set([ 'src/daemon/session-capture-binding.ts', - 'src/daemon/screen-recording-session-binding.ts', 'src/daemon/session-state.ts', ]), perfCapture: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']),