@oortmem/core 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/isolation.js CHANGED
@@ -1,4 +1,11 @@
1
- import { GrantError, localGrant } from './grant.js';
1
+ /**
2
+ * Isolation checks for `MemoryStore` implementations (CON-60). Plain
3
+ * functions that return the violations they find, so the conformance suite
4
+ * can require an empty list and a test can prove a broken store is caught.
5
+ * No vitest here; `store-conformance.ts` wires these into `it` blocks.
6
+ */
7
+ import { addressed, addressOf, authorOf, GALAXY_ROOT, nodeOf, } from './address.js';
8
+ import { GrantError, localGrant, nodeGrant } from './grant.js';
2
9
  const NOW = '2026-11-04T12:00:00.000Z';
3
10
  /** A memory for the isolation checks: text, stub and entities all carry `marker`. */
4
11
  export function isoMemory(id, scope, marker, embedding) {
@@ -27,10 +34,48 @@ export function isoMemory(id, scope, marker, embedding) {
27
34
  };
28
35
  }
29
36
  const principal = (s) => ({ orgId: s.orgId, customerId: s.customerId, agentId: s.agentId });
30
- /** The narrowest grant for one scope (its principal and its one project or global). */
37
+ /**
38
+ * The narrowest grant for one scope: its Planet, its one project (or global),
39
+ * memories its own agent wrote. (The pre-CON-62 local grant; today's
40
+ * `localGrant` also covers the Planet's other authors and the agent's Star.)
41
+ */
31
42
  export function soleGrant(s) {
43
+ return nodeGrant({
44
+ principal: principal(s),
45
+ read: [
46
+ {
47
+ node: { level: 'user', orgId: s.orgId, customerId: s.customerId },
48
+ projectIds: [s.projectId ?? null],
49
+ authorAgentId: s.agentId,
50
+ },
51
+ ],
52
+ });
53
+ }
54
+ /** The local grant of `ISO_SCOPES.mine`'s agent for its one project: Planet plus its own Star. */
55
+ export function defaultGrant(s) {
32
56
  return localGrant({ ...principal(s), projectIds: [s.projectId ?? null] });
33
57
  }
58
+ /** The narrowest grant for one memory at any level: its node, its one project, its author. */
59
+ export function soleGrantFor(m) {
60
+ const address = addressOf(m);
61
+ const author = authorOf(m);
62
+ const pattern = {
63
+ node: nodeOf(address),
64
+ projectIds: [address.projectId ?? null],
65
+ authorAgentId: author.agentId,
66
+ };
67
+ return nodeGrant({ principal: ISO_PRINCIPAL, read: [pattern] });
68
+ }
69
+ /** `m` moved to `scope`: its address and author dropped, so the scope's Planet is the address. */
70
+ export function movedTo(m, scope) {
71
+ const { address: _a, author: _w, provenance: _p, ...rest } = m;
72
+ return { ...rest, scope };
73
+ }
74
+ /** A memory at `address`, written by `author`, for the isolation checks. */
75
+ export function isoAt(id, address, author, marker, embedding) {
76
+ const at = addressed(address, author);
77
+ return { ...isoMemory(id, at.scope, marker, embedding), ...at };
78
+ }
34
79
  /** A stable rendering of a record (embedding as plain numbers) for before/after comparison. */
35
80
  function render(m) {
36
81
  if (m === null)
@@ -59,6 +104,70 @@ export const ISO_SCOPES = {
59
104
  otherAgent: { orgId: 'local', customerId: 'self', agentId: 'another-agent', projectId: 'pa' },
60
105
  otherOrg: { orgId: 'another-org', customerId: 'self', agentId: 'claude-code', projectId: 'pa' },
61
106
  };
107
+ /** Who the level checks act as: the caller of `ISO_SCOPES.mine`. */
108
+ const ISO_PRINCIPAL = { orgId: 'local', customerId: 'self', agentId: 'claude-code' };
109
+ /**
110
+ * Victims at the other levels, each next to the caller's own Planet: a Planet
111
+ * memory another of the caller's agents wrote, the caller's agent's own Star,
112
+ * another agent's Star (in the caller's project and global), an Orbit, a
113
+ * Flyby, a Constellation and a Galaxy root of an organisation, and one of the
114
+ * caller's own owner. The narrowest grant sees none of them; the local grant
115
+ * sees only those in `DEFAULT_COVERS`.
116
+ */
117
+ export const ISO_LEVELS = {
118
+ planetOtherAuthor: {
119
+ address: { level: 'user', orgId: 'local', customerId: 'self', projectId: 'pa' },
120
+ author: { agentId: 'cursor' },
121
+ },
122
+ star: {
123
+ address: { level: 'agent', orgId: 'local', agentId: 'claude-code', projectId: 'pa' },
124
+ author: { agentId: 'claude-code' },
125
+ },
126
+ otherStar: {
127
+ address: { level: 'agent', orgId: 'local', agentId: 'cursor', projectId: 'pa' },
128
+ author: { agentId: 'cursor' },
129
+ },
130
+ otherStarGlobal: {
131
+ address: { level: 'agent', orgId: 'local', agentId: 'cursor' },
132
+ author: { agentId: 'cursor' },
133
+ },
134
+ orbit: {
135
+ address: { level: 'agent-user', orgId: 'local', agentId: 'claude-code', customerId: 'self' },
136
+ author: { agentId: 'claude-code' },
137
+ },
138
+ flyby: {
139
+ address: {
140
+ level: 'run',
141
+ orgId: 'local',
142
+ agentId: 'claude-code',
143
+ runId: 'r1',
144
+ customerId: 'self',
145
+ projectId: 'pa',
146
+ },
147
+ author: { agentId: 'claude-code' },
148
+ },
149
+ ownHive: {
150
+ address: { level: 'hive', orgId: 'local', hiveId: 'self', projectId: 'pa' },
151
+ author: { agentId: 'claude-code' },
152
+ },
153
+ constellation: {
154
+ address: { level: 'hive', orgId: 'org:acme', hiveId: 'checkout', projectId: 'pa' },
155
+ author: { agentId: 'claude-code', accountRef: 'acct:member' },
156
+ },
157
+ galaxy: {
158
+ address: { level: 'hive', orgId: 'org:acme', hiveId: GALAXY_ROOT },
159
+ author: { agentId: 'claude-code' },
160
+ },
161
+ };
162
+ /**
163
+ * The victims the local grant of `ISO_SCOPES.mine` legitimately covers (CON-62): Planet memories
164
+ * the person's other agents wrote, and its agent's own Star. Never another agent's Star.
165
+ */
166
+ export const DEFAULT_COVERS = Object.freeze([
167
+ 'otherAgent',
168
+ 'planetOtherAuthor',
169
+ 'star',
170
+ ]);
62
171
  /**
63
172
  * Every `MemoryStore` method, called with a grant for `ISO_SCOPES.mine`
64
173
  * against memories (warm and cold) in each other scope: nothing is seen,
@@ -66,6 +175,10 @@ export const ISO_SCOPES = {
66
175
  * apart. Also the write-side rules (`GrantError` for an out-of-grant scope
67
176
  * and for an id that exists elsewhere, atomic batches, address changes),
68
177
  * read/write splits, cursor namespaces and rejection of non-grants.
178
+ *
179
+ * Run twice: as the narrowest grant (every victim out of reach), and as the
180
+ * local grant (`localGrant`, Planet plus own Star), for which every victim
181
+ * but `DEFAULT_COVERS` is out of reach, other agents' Stars included.
69
182
  */
70
183
  export async function isolationViolations(make) {
71
184
  const out = [];
@@ -76,13 +189,23 @@ export async function isolationViolations(make) {
76
189
  await store.upsert(mine, isoMemory('mine-1', ISO_SCOPES.mine, 'shared', vec(1)));
77
190
  const victims = [];
78
191
  let n = 2;
79
- for (const [name, scope] of Object.entries(ISO_SCOPES)) {
80
- if (name === 'mine')
81
- continue;
192
+ const targets = [
193
+ ...Object.entries(ISO_SCOPES)
194
+ .filter(([name]) => name !== 'mine')
195
+ .map(([name, scope]) => [
196
+ name,
197
+ (id, marker, v) => isoMemory(id, scope, marker, v),
198
+ ]),
199
+ ...Object.entries(ISO_LEVELS).map(([name, at]) => [
200
+ name,
201
+ (id, marker, v) => isoAt(id, at.address, at.author, marker, v),
202
+ ]),
203
+ ];
204
+ for (const [name, build] of targets) {
82
205
  for (const cold of [false, true]) {
83
206
  const id = `victim-${name}-${cold ? 'cold' : 'warm'}`;
84
- const g = soleGrant(scope);
85
- const m = isoMemory(id, scope, `shared canary${name}`, vec(n++));
207
+ const m = build(id, `shared canary${name}`, vec(n++));
208
+ const g = soleGrantFor(m);
86
209
  await store.upsert(g, m);
87
210
  if (cold) {
88
211
  const { skipped } = await store.coldPut(g, m);
@@ -93,62 +216,12 @@ export async function isolationViolations(make) {
93
216
  }
94
217
  }
95
218
  const snapshot = async () => Promise.all(victims.map(async (v) => `${render(await store.get(v.g, v.m.id))}|${render(await store.coldGet(v.g, v.m.id))}`));
96
- const before = await snapshot();
97
- for (const v of victims) {
98
- const id = v.m.id;
99
- const fail = (what) => out.push(`${v.name}: ${what}`);
100
- if ((await store.get(mine, id)) !== null)
101
- fail('get returned it');
102
- if ((await store.coldGet(mine, id)) !== null)
103
- fail('coldGet returned it');
104
- const lex = await store.lexical(mine, 'shared canary', 50);
105
- if (lex.some((h) => h.id === id))
106
- fail('lexical returned it');
107
- const den = await store.dense(mine, v.m.embedding ?? vec(1), 50);
108
- if (den.some((h) => h.id === id))
109
- fail('dense returned it');
110
- for (const validOnly of [true, false]) {
111
- if ((await store.list(mine, { validOnly })).some((m) => m.id === id))
112
- fail('list returned it');
113
- }
114
- await store.recordAccess(mine, [id], new Date(NOW));
115
- if ((await store.setTier(mine, id, 'hot')) !== false)
116
- fail('setTier reported a write');
117
- if ((await store.setTier(mine, id, 'hot', v.m)) !== false)
118
- fail('guarded setTier reported a write');
119
- const exp = await store.expireMany(mine, [v.m], new Date(NOW));
120
- if (!exp.skipped.includes(id))
121
- fail('expireMany did not skip it');
122
- for (const r of [await store.coldPut(mine, v.m), await store.coldPutMany(mine, [v.m])]) {
123
- if (!r.skipped.includes(id))
124
- fail('coldPut did not skip it');
125
- }
126
- await store.forget(mine, id);
127
- const direct = await settle(store.upsert(mine, v.m));
128
- if (!('err' in direct) || !(direct.err instanceof GrantError)) {
129
- fail('upsert of its own scope was not refused with GrantError');
130
- }
131
- const moved = await settle(store.upsert(mine, { ...v.m, scope: ISO_SCOPES.mine }));
132
- if (!('err' in moved) || !(moved.err instanceof GrantError)) {
133
- fail('upsert moving its id into my scope was not refused with GrantError');
134
- }
135
- else if ('err' in direct &&
136
- direct.err.message !== moved.err.message) {
137
- fail('the two refusals have different messages');
138
- }
139
- const batch = await settle(store.upsertMany(mine, [
140
- isoMemory(`batch-${id}`, ISO_SCOPES.mine, 'batch'),
141
- { ...v.m, scope: ISO_SCOPES.mine },
142
- ]));
143
- if (!('err' in batch))
144
- fail('a batch reaching it was not refused');
145
- if ((await store.get(mine, `batch-${id}`)) !== null)
146
- fail('a refused batch wrote part of itself');
147
- }
148
- const after = await snapshot();
149
- for (const [i, v] of victims.entries()) {
150
- if (before[i] !== after[i])
151
- out.push(`${v.name}: changed through a grant that cannot see it`);
219
+ for (const [caller, grant, covered] of [
220
+ ['narrowest', mine, []],
221
+ ['local', defaultGrant(ISO_SCOPES.mine), DEFAULT_COVERS],
222
+ ]) {
223
+ const reach = victims.filter((v) => !covered.some((c) => v.name === `victim-${c}-warm` || v.name === `victim-${c}-cold`));
224
+ out.push(...(await victimViolations(store, caller, grant, reach, snapshot, victims)));
152
225
  }
153
226
  // An address change of my own record is refused too, and leaves it as it was.
154
227
  const own = await store.get(mine, 'mine-1');
@@ -229,6 +302,68 @@ export async function isolationViolations(make) {
229
302
  }
230
303
  return out;
231
304
  }
305
+ /** One caller's pass over the victims it must not reach: nothing seen, nothing changed. */
306
+ async function victimViolations(store, caller, mine, victims, snapshot, all) {
307
+ const out = [];
308
+ const vec = (n) => Int8Array.from([n, 1, 1, 1]);
309
+ const before = await snapshot();
310
+ for (const v of victims) {
311
+ const id = v.m.id;
312
+ const fail = (what) => out.push(`${caller}: ${v.name}: ${what}`);
313
+ if ((await store.get(mine, id)) !== null)
314
+ fail('get returned it');
315
+ if ((await store.coldGet(mine, id)) !== null)
316
+ fail('coldGet returned it');
317
+ const lex = await store.lexical(mine, 'shared canary', 50);
318
+ if (lex.some((h) => h.id === id))
319
+ fail('lexical returned it');
320
+ const den = await store.dense(mine, v.m.embedding ?? vec(1), 50);
321
+ if (den.some((h) => h.id === id))
322
+ fail('dense returned it');
323
+ for (const validOnly of [true, false]) {
324
+ if ((await store.list(mine, { validOnly })).some((m) => m.id === id))
325
+ fail('list returned it');
326
+ }
327
+ await store.recordAccess(mine, [id], new Date(NOW));
328
+ if ((await store.setTier(mine, id, 'hot')) !== false)
329
+ fail('setTier reported a write');
330
+ if ((await store.setTier(mine, id, 'hot', v.m)) !== false)
331
+ fail('guarded setTier reported a write');
332
+ const exp = await store.expireMany(mine, [v.m], new Date(NOW));
333
+ if (!exp.skipped.includes(id))
334
+ fail('expireMany did not skip it');
335
+ for (const r of [await store.coldPut(mine, v.m), await store.coldPutMany(mine, [v.m])]) {
336
+ if (!r.skipped.includes(id))
337
+ fail('coldPut did not skip it');
338
+ }
339
+ await store.forget(mine, id);
340
+ const direct = await settle(store.upsert(mine, v.m));
341
+ if (!('err' in direct) || !(direct.err instanceof GrantError)) {
342
+ fail('upsert of its own scope was not refused with GrantError');
343
+ }
344
+ const moved = await settle(store.upsert(mine, movedTo(v.m, ISO_SCOPES.mine)));
345
+ if (!('err' in moved) || !(moved.err instanceof GrantError)) {
346
+ fail('upsert moving its id into my scope was not refused with GrantError');
347
+ }
348
+ else if ('err' in direct && direct.err.message !== moved.err.message) {
349
+ fail('the two refusals have different messages');
350
+ }
351
+ const batch = await settle(store.upsertMany(mine, [
352
+ isoMemory(`batch-${id}`, ISO_SCOPES.mine, 'batch'),
353
+ movedTo(v.m, ISO_SCOPES.mine),
354
+ ]));
355
+ if (!('err' in batch))
356
+ fail('a batch reaching it was not refused');
357
+ if ((await store.get(mine, `batch-${id}`)) !== null)
358
+ fail('a refused batch wrote part of itself');
359
+ }
360
+ const after = await snapshot();
361
+ for (const [i, v] of all.entries()) {
362
+ if (before[i] !== after[i])
363
+ out.push(`${caller}: ${v.name}: changed through a grant that cannot see it`);
364
+ }
365
+ return out;
366
+ }
232
367
  /** A small seeded PRNG (mulberry32), so a failing seed replays exactly. */
233
368
  export function seededRng(seed) {
234
369
  let a = seed >>> 0;
@@ -253,12 +388,56 @@ const WORDS = ['orbit', 'comet', 'nebula', 'pulsar', 'quasar', 'photon', 'kuiper
253
388
  * keys (callers use UUIDs), so a tenant that reuses another tenant's id is
254
389
  * refused, which is a collision, not a leak of content.
255
390
  */
256
- function opSequence(seed, steps) {
391
+ function opSequence(seed, steps, mode) {
392
+ if (mode === 'peers')
393
+ return peerSequence(seed, steps);
394
+ const multiNode = mode === 'multi';
257
395
  const rng = seededRng(seed);
258
- const aScope = (i) => i === 0 ? ISO_SCOPES.mine : i === 1 ? ISO_SCOPES.otherProject : ISO_SCOPES.global;
259
- const aGrant = localGrant({ ...principal(ISO_SCOPES.mine), projectIds: ['pa', 'pa2', null] });
396
+ const planetA = ['pa', 'pa2', null].map((projectId) => (id, t, e) => isoMemory(id, projectId === null ? ISO_SCOPES.global : { ...ISO_SCOPES.mine, projectId }, t, e));
397
+ // With multiNode, A also reads and writes a Constellation of another owner and its
398
+ // agent's own Star; B (another person) its own Planet and a sibling Constellation.
399
+ const aBuild = multiNode
400
+ ? [
401
+ ...planetA,
402
+ (id, t, e) => isoAt(id, ISO_LEVELS['constellation'].address, { agentId: 'claude-code' }, t, e),
403
+ (id, t, e) => isoAt(id, ISO_LEVELS['star'].address, { agentId: 'claude-code' }, t, e),
404
+ ]
405
+ : planetA;
406
+ const aGrant = multiNode
407
+ ? nodeGrant({
408
+ principal: ISO_PRINCIPAL,
409
+ read: [
410
+ {
411
+ node: { level: 'user', orgId: 'local', customerId: 'self' },
412
+ projectIds: ['pa', 'pa2', null],
413
+ authorAgentId: 'claude-code',
414
+ },
415
+ { node: { level: 'hive', orgId: 'org:acme', hiveId: 'checkout' }, projectIds: 'all' },
416
+ { node: { level: 'agent', orgId: 'local', agentId: 'claude-code' }, projectIds: 'all' },
417
+ ],
418
+ })
419
+ : localGrant({ ...principal(ISO_SCOPES.mine), projectIds: ['pa', 'pa2', null] });
260
420
  const bScope = ISO_SCOPES.otherCustomer;
261
- const bGrant = soleGrant(bScope);
421
+ const payments = { level: 'hive', orgId: 'org:acme', hiveId: 'payments' };
422
+ const bBuild = multiNode
423
+ ? [
424
+ (id, t, e) => isoMemory(id, bScope, t, e),
425
+ (id, t, e) => isoAt(id, payments, { agentId: 'claude-code' }, t, e),
426
+ ]
427
+ : [(id, t, e) => isoMemory(id, bScope, t, e)];
428
+ const bGrant = multiNode
429
+ ? nodeGrant({
430
+ principal: principal(bScope),
431
+ read: [
432
+ {
433
+ node: { level: 'user', orgId: 'local', customerId: 'someone-else' },
434
+ projectIds: ['pa'],
435
+ authorAgentId: 'claude-code',
436
+ },
437
+ { node: nodeOf(payments), projectIds: 'all' },
438
+ ],
439
+ })
440
+ : soleGrant(bScope);
262
441
  const ops = [];
263
442
  const aIds = [];
264
443
  const bIds = [];
@@ -278,8 +457,10 @@ function opSequence(seed, steps) {
278
457
  if (kind <= 2 || own.length === 0) {
279
458
  const id = `${who}-${i}`;
280
459
  own.push(id);
281
- const scope = who === 'A' ? aScope(rng.int(3)) : bScope;
282
- const m = { ...isoMemory(id, scope, text(), emb()), salience: rng.int(10) / 10 };
460
+ const builds = who === 'A' ? aBuild : bBuild;
461
+ // One builder draws no number, so the pre-level sequences replay exactly.
462
+ const build = builds.length === 1 ? builds[0] : builds[rng.int(builds.length)];
463
+ const m = { ...build(id, text(), emb()), salience: rng.int(10) / 10 };
283
464
  ops.push({ who, run: (s) => s.upsert(grant, m) });
284
465
  }
285
466
  else if (kind === 3) {
@@ -327,6 +508,107 @@ function opSequence(seed, steps) {
327
508
  }
328
509
  return ops;
329
510
  }
511
+ /**
512
+ * Two agents of one person (see `nonInterference`'s `peers`): A is `claude-code`, B is `cursor`,
513
+ * each with its local grant over projects pa, pa2 and global.
514
+ */
515
+ function peerSequence(seed, steps) {
516
+ const rng = seededRng(seed);
517
+ const person = { orgId: 'local', customerId: 'self' };
518
+ const projects = ['pa', 'pa2', null];
519
+ const aGrant = localGrant({ ...person, agentId: 'claude-code', projectIds: [...projects] });
520
+ const bGrant = localGrant({ ...person, agentId: 'cursor', projectIds: [...projects] });
521
+ const star = (agentId, projectId) => ({
522
+ level: 'agent',
523
+ orgId: 'local',
524
+ agentId,
525
+ ...(projectId !== null && { projectId }),
526
+ });
527
+ const ops = [];
528
+ const aIds = [];
529
+ const aStarIds = [];
530
+ const bIds = [];
531
+ const text = () => `${rng.pick(WORDS)} ${rng.pick(WORDS)} ${rng.pick(WORDS)}`;
532
+ const emb = () => Int8Array.from([rng.int(9) - 4, rng.int(9) - 4, rng.int(9) - 4, 1]);
533
+ for (let i = 0; i < steps; i++) {
534
+ const who = rng.int(2) === 0 ? 'A' : 'B';
535
+ const grant = who === 'A' ? aGrant : bGrant;
536
+ const own = who === 'A' ? aIds : bIds;
537
+ // B aims at A's Star ids half the time (A's Planet is B's to share); A only at its own.
538
+ const target = () => who === 'B' && aStarIds.length > 0 && rng.int(2) === 0
539
+ ? rng.pick(aStarIds)
540
+ : own.length > 0
541
+ ? rng.pick(own)
542
+ : 'none';
543
+ const kind = rng.int(12);
544
+ if (kind <= 2 || own.length === 0) {
545
+ const id = `${who}-${i}`;
546
+ own.push(id);
547
+ const projectId = rng.pick(projects);
548
+ let m;
549
+ if (who === 'B') {
550
+ m = isoAt(id, star('cursor', projectId), { agentId: 'cursor' }, text(), emb());
551
+ }
552
+ else if (rng.int(2) === 0) {
553
+ aStarIds.push(id);
554
+ m = isoAt(id, star('claude-code', projectId), { agentId: 'claude-code' }, text(), emb());
555
+ }
556
+ else {
557
+ const scope = {
558
+ ...person,
559
+ agentId: 'claude-code',
560
+ ...(projectId !== null && { projectId }),
561
+ };
562
+ m = isoMemory(id, scope, text(), emb());
563
+ }
564
+ const salted = { ...m, salience: rng.int(10) / 10 };
565
+ ops.push({ who, run: (s) => s.upsert(grant, salted) });
566
+ }
567
+ else if (kind === 3) {
568
+ const id = target();
569
+ ops.push({ who, run: async (s) => render(await s.get(grant, id)) });
570
+ }
571
+ else if (kind === 4) {
572
+ const q = text();
573
+ ops.push({ who, run: async (s) => (await s.lexical(grant, q, 100)).map((h) => h.id).sort() });
574
+ }
575
+ else if (kind === 5) {
576
+ const v = emb();
577
+ ops.push({ who, run: async (s) => s.dense(grant, v, 5) });
578
+ }
579
+ else if (kind === 6) {
580
+ ops.push({ who, run: async (s) => (await s.list(grant, { validOnly: false })).map(render) });
581
+ }
582
+ else if (kind === 7) {
583
+ const id = target();
584
+ ops.push({
585
+ who,
586
+ run: async (s) => {
587
+ const m = await s.get(grant, id);
588
+ return m === null ? 'none' : s.coldPut(grant, m);
589
+ },
590
+ });
591
+ }
592
+ else if (kind === 8) {
593
+ const id = target();
594
+ ops.push({ who, run: async (s) => render(await s.coldGet(grant, id)) });
595
+ }
596
+ else if (kind === 9) {
597
+ const id = target();
598
+ const tier = rng.pick(['hot', 'warm']);
599
+ ops.push({ who, run: (s) => s.setTier(grant, id, tier) });
600
+ }
601
+ else if (kind === 10) {
602
+ const ids = [target(), target()];
603
+ ops.push({ who, run: (s) => s.recordAccess(grant, ids, new Date(NOW)) });
604
+ }
605
+ else {
606
+ const id = target();
607
+ ops.push({ who, run: (s) => s.forget(grant, id) });
608
+ }
609
+ }
610
+ return ops;
611
+ }
330
612
  /**
331
613
  * Non-interference: tenant A's observable results (return values, errors,
332
614
  * order and scores) are the same whether or not tenant B ran its operations
@@ -337,9 +619,18 @@ function opSequence(seed, steps) {
337
619
  * order: SQLite FTS5's bm25 uses table-wide term statistics, so another
338
620
  * tenant's text shifts scores (a known side channel; see the PR notes).
339
621
  * Every other result is compared exactly, dense scores included.
622
+ *
623
+ * `multiNode`: A's grant spans its Planet, its agent's Star and a
624
+ * Constellation of another owner; B's spans its own Planet and a sibling
625
+ * Constellation. Same property.
626
+ *
627
+ * `peers` (CON-62): A and B are two agents of one person, each with the local
628
+ * grant (the shared Planet plus its own Star). A writes to the Planet and its
629
+ * Star; B writes only to its own Star and aims every method at A's Star ids.
630
+ * Nothing B does through its Star reaches A.
340
631
  */
341
- export async function nonInterference(make, seed, steps) {
342
- const ops = opSequence(seed, steps);
632
+ export async function nonInterference(make, seed, steps, opts = {}) {
633
+ const ops = opSequence(seed, steps, opts.peers === true ? 'peers' : opts.multiNode === true ? 'multi' : 'single');
343
634
  const observe = async (withB) => {
344
635
  const s = await make();
345
636
  const seen = [];
@@ -0,0 +1,79 @@
1
+ import { type Level, type NodeRef } from './address.js';
2
+ import { type Grant } from './grant.js';
3
+ import type { Memory } from './model.js';
4
+ /**
5
+ * The levels a recall can name in `include` (spec 7.1): `self` is the
6
+ * caller's home node, `planet` the person's Planet, `star` the calling agent's
7
+ * own Star, `constellation` and `galaxy` the hive nodes. Always intersected
8
+ * with the grant.
9
+ */
10
+ export type IncludeLevel = 'self' | 'planet' | 'star' | 'constellation' | 'galaxy';
11
+ export declare const INCLUDE_LEVELS: readonly IncludeLevel[];
12
+ export declare const isIncludeLevel: (s: string) => s is IncludeLevel;
13
+ /** At most this many hive nodes are read by default (spec 7.1: one recall, a bounded fan-out). */
14
+ export declare const DEFAULT_HIVE_NODES = 4;
15
+ /**
16
+ * A recall asked for a level its grant does not cover. Typed, so callers tell
17
+ * "you may not read that" apart from "nothing matched" (spec 7.1).
18
+ */
19
+ export declare class ScopeDeniedError extends Error {
20
+ readonly code: 'scope_denied';
21
+ readonly levels: IncludeLevel[];
22
+ constructor(levels: IncludeLevel[]);
23
+ }
24
+ /**
25
+ * The nodes one recall reads: with `include`, every readable node a named
26
+ * level covers (a level that covers none rejects with `ScopeDeniedError`);
27
+ * without it, every readable node, hive nodes capped at `DEFAULT_HIVE_NODES`
28
+ * in grant order. Never a node outside `g`.
29
+ */
30
+ export declare function nodesToRead(g: Grant, include?: readonly IncludeLevel[]): NodeRef[];
31
+ /**
32
+ * How specific a node is, for the specificity prior and the conflict rule:
33
+ * an agent's own memory (Star, Orbit, Flyby) 3, the person's Planet 2, a
34
+ * Constellation 1, a Galaxy 0. Narrower is higher.
35
+ */
36
+ export declare function specificity(node: Pick<NodeRef, 'level'> & {
37
+ hiveId?: string;
38
+ }): number;
39
+ /**
40
+ * The specificity prior's factor for a node: `1 + ε × specificity / 3`, so
41
+ * with `ε` the narrowest level gains `ε` and a Galaxy nothing. `ε = 0` (the
42
+ * default) leaves every score as it was.
43
+ */
44
+ export declare function specificityFactor(node: NodeRef, epsilon: number): number;
45
+ /** Brackets in memory text, escaped so the text cannot pass for a code-built label. */
46
+ export declare function escapeLabelText(text: string): string;
47
+ /** Where a recalled memory came from, built by code from its address and author. */
48
+ export interface MemoryOrigin {
49
+ level: Level;
50
+ /** The space name: Planet, Star, Orbit, Flyby, Constellation or Galaxy. */
51
+ space: string;
52
+ /** The node, as a label shows it: `your Planet`, `Star cursor`, `Constellation checkout`. */
53
+ node: string;
54
+ /** For people: `from your Planet`, `from this agent's Star`, `from Star cursor`. */
55
+ from: string;
56
+ /** The memory lives in the caller's home node. */
57
+ home: boolean;
58
+ /** The agent that wrote it. */
59
+ author: string;
60
+ /**
61
+ * A narrower memory in this result contradicts this one (spec 7.2): it ranks
62
+ * first, and this one stays, labelled.
63
+ */
64
+ overriddenBy?: {
65
+ id: string;
66
+ node: string;
67
+ };
68
+ }
69
+ /** The node part of a label for `node`, as the caller of grant `g` sees it. */
70
+ export declare function nodeLabel(node: NodeRef, g: Grant): string;
71
+ /** The origin of `m` for the caller of grant `g`. */
72
+ export declare function originOf(m: Pick<Memory, 'scope' | 'address' | 'author'>, g: Grant): MemoryOrigin;
73
+ /** The decision `observe` records on a memory written instead of editing one in another node. */
74
+ export declare const CONTRADICTS: {
75
+ readonly stage: 'level';
76
+ readonly question: 'contradicts';
77
+ };
78
+ /** The ids of memories in other nodes that `m` contradicts (recorded by `observe`). */
79
+ export declare function contradicted(m: Pick<Memory, 'source'>): string[];