@oortmem/core 0.2.0 → 0.3.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) {
@@ -31,6 +38,27 @@ const principal = (s) => ({ orgId: s.orgId, customerId: s.customerId, agentId: s
31
38
  export function soleGrant(s) {
32
39
  return localGrant({ ...principal(s), projectIds: [s.projectId ?? null] });
33
40
  }
41
+ /** The narrowest grant for one memory at any level: its node, its one project, its author. */
42
+ export function soleGrantFor(m) {
43
+ const address = addressOf(m);
44
+ const author = authorOf(m);
45
+ const pattern = {
46
+ node: nodeOf(address),
47
+ projectIds: [address.projectId ?? null],
48
+ authorAgentId: author.agentId,
49
+ };
50
+ return nodeGrant({ principal: ISO_PRINCIPAL, read: [pattern] });
51
+ }
52
+ /** `m` moved to `scope`: its address and author dropped, so the scope's Planet is the address. */
53
+ export function movedTo(m, scope) {
54
+ const { address: _a, author: _w, provenance: _p, ...rest } = m;
55
+ return { ...rest, scope };
56
+ }
57
+ /** A memory at `address`, written by `author`, for the isolation checks. */
58
+ export function isoAt(id, address, author, marker, embedding) {
59
+ const at = addressed(address, author);
60
+ return { ...isoMemory(id, at.scope, marker, embedding), ...at };
61
+ }
34
62
  /** A stable rendering of a record (embedding as plain numbers) for before/after comparison. */
35
63
  function render(m) {
36
64
  if (m === null)
@@ -59,6 +87,51 @@ export const ISO_SCOPES = {
59
87
  otherAgent: { orgId: 'local', customerId: 'self', agentId: 'another-agent', projectId: 'pa' },
60
88
  otherOrg: { orgId: 'another-org', customerId: 'self', agentId: 'claude-code', projectId: 'pa' },
61
89
  };
90
+ /** Who the level checks act as: the caller of `ISO_SCOPES.mine`. */
91
+ const ISO_PRINCIPAL = { orgId: 'local', customerId: 'self', agentId: 'claude-code' };
92
+ /**
93
+ * Victims at the other levels, each next to the caller's own Planet: a Planet
94
+ * memory another of the caller's agents wrote, the caller's agent's own Star,
95
+ * an Orbit, a Flyby, a Constellation and a Galaxy root of an organisation, and
96
+ * one of the caller's own owner. A pre-level grant sees none of them.
97
+ */
98
+ export const ISO_LEVELS = {
99
+ planetOtherAuthor: {
100
+ address: { level: 'user', orgId: 'local', customerId: 'self', projectId: 'pa' },
101
+ author: { agentId: 'cursor' },
102
+ },
103
+ star: {
104
+ address: { level: 'agent', orgId: 'local', agentId: 'claude-code', projectId: 'pa' },
105
+ author: { agentId: 'claude-code' },
106
+ },
107
+ orbit: {
108
+ address: { level: 'agent-user', orgId: 'local', agentId: 'claude-code', customerId: 'self' },
109
+ author: { agentId: 'claude-code' },
110
+ },
111
+ flyby: {
112
+ address: {
113
+ level: 'run',
114
+ orgId: 'local',
115
+ agentId: 'claude-code',
116
+ runId: 'r1',
117
+ customerId: 'self',
118
+ projectId: 'pa',
119
+ },
120
+ author: { agentId: 'claude-code' },
121
+ },
122
+ ownHive: {
123
+ address: { level: 'hive', orgId: 'local', hiveId: 'self', projectId: 'pa' },
124
+ author: { agentId: 'claude-code' },
125
+ },
126
+ constellation: {
127
+ address: { level: 'hive', orgId: 'org:acme', hiveId: 'checkout', projectId: 'pa' },
128
+ author: { agentId: 'claude-code', accountRef: 'acct:member' },
129
+ },
130
+ galaxy: {
131
+ address: { level: 'hive', orgId: 'org:acme', hiveId: GALAXY_ROOT },
132
+ author: { agentId: 'claude-code' },
133
+ },
134
+ };
62
135
  /**
63
136
  * Every `MemoryStore` method, called with a grant for `ISO_SCOPES.mine`
64
137
  * against memories (warm and cold) in each other scope: nothing is seen,
@@ -76,13 +149,23 @@ export async function isolationViolations(make) {
76
149
  await store.upsert(mine, isoMemory('mine-1', ISO_SCOPES.mine, 'shared', vec(1)));
77
150
  const victims = [];
78
151
  let n = 2;
79
- for (const [name, scope] of Object.entries(ISO_SCOPES)) {
80
- if (name === 'mine')
81
- continue;
152
+ const targets = [
153
+ ...Object.entries(ISO_SCOPES)
154
+ .filter(([name]) => name !== 'mine')
155
+ .map(([name, scope]) => [
156
+ name,
157
+ (id, marker, v) => isoMemory(id, scope, marker, v),
158
+ ]),
159
+ ...Object.entries(ISO_LEVELS).map(([name, at]) => [
160
+ name,
161
+ (id, marker, v) => isoAt(id, at.address, at.author, marker, v),
162
+ ]),
163
+ ];
164
+ for (const [name, build] of targets) {
82
165
  for (const cold of [false, true]) {
83
166
  const id = `victim-${name}-${cold ? 'cold' : 'warm'}`;
84
- const g = soleGrant(scope);
85
- const m = isoMemory(id, scope, `shared canary${name}`, vec(n++));
167
+ const m = build(id, `shared canary${name}`, vec(n++));
168
+ const g = soleGrantFor(m);
86
169
  await store.upsert(g, m);
87
170
  if (cold) {
88
171
  const { skipped } = await store.coldPut(g, m);
@@ -128,7 +211,7 @@ export async function isolationViolations(make) {
128
211
  if (!('err' in direct) || !(direct.err instanceof GrantError)) {
129
212
  fail('upsert of its own scope was not refused with GrantError');
130
213
  }
131
- const moved = await settle(store.upsert(mine, { ...v.m, scope: ISO_SCOPES.mine }));
214
+ const moved = await settle(store.upsert(mine, movedTo(v.m, ISO_SCOPES.mine)));
132
215
  if (!('err' in moved) || !(moved.err instanceof GrantError)) {
133
216
  fail('upsert moving its id into my scope was not refused with GrantError');
134
217
  }
@@ -138,7 +221,7 @@ export async function isolationViolations(make) {
138
221
  }
139
222
  const batch = await settle(store.upsertMany(mine, [
140
223
  isoMemory(`batch-${id}`, ISO_SCOPES.mine, 'batch'),
141
- { ...v.m, scope: ISO_SCOPES.mine },
224
+ movedTo(v.m, ISO_SCOPES.mine),
142
225
  ]));
143
226
  if (!('err' in batch))
144
227
  fail('a batch reaching it was not refused');
@@ -253,12 +336,53 @@ const WORDS = ['orbit', 'comet', 'nebula', 'pulsar', 'quasar', 'photon', 'kuiper
253
336
  * keys (callers use UUIDs), so a tenant that reuses another tenant's id is
254
337
  * refused, which is a collision, not a leak of content.
255
338
  */
256
- function opSequence(seed, steps) {
339
+ function opSequence(seed, steps, multiNode) {
257
340
  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] });
341
+ const planetA = ['pa', 'pa2', null].map((projectId) => (id, t, e) => isoMemory(id, projectId === null ? ISO_SCOPES.global : { ...ISO_SCOPES.mine, projectId }, t, e));
342
+ // With multiNode, A also reads and writes a Constellation of another owner and its
343
+ // agent's own Star; B (another person) its own Planet and a sibling Constellation.
344
+ const aBuild = multiNode
345
+ ? [
346
+ ...planetA,
347
+ (id, t, e) => isoAt(id, ISO_LEVELS['constellation'].address, { agentId: 'claude-code' }, t, e),
348
+ (id, t, e) => isoAt(id, ISO_LEVELS['star'].address, { agentId: 'claude-code' }, t, e),
349
+ ]
350
+ : planetA;
351
+ const aGrant = multiNode
352
+ ? nodeGrant({
353
+ principal: ISO_PRINCIPAL,
354
+ read: [
355
+ {
356
+ node: { level: 'user', orgId: 'local', customerId: 'self' },
357
+ projectIds: ['pa', 'pa2', null],
358
+ authorAgentId: 'claude-code',
359
+ },
360
+ { node: { level: 'hive', orgId: 'org:acme', hiveId: 'checkout' }, projectIds: 'all' },
361
+ { node: { level: 'agent', orgId: 'local', agentId: 'claude-code' }, projectIds: 'all' },
362
+ ],
363
+ })
364
+ : localGrant({ ...principal(ISO_SCOPES.mine), projectIds: ['pa', 'pa2', null] });
260
365
  const bScope = ISO_SCOPES.otherCustomer;
261
- const bGrant = soleGrant(bScope);
366
+ const payments = { level: 'hive', orgId: 'org:acme', hiveId: 'payments' };
367
+ const bBuild = multiNode
368
+ ? [
369
+ (id, t, e) => isoMemory(id, bScope, t, e),
370
+ (id, t, e) => isoAt(id, payments, { agentId: 'claude-code' }, t, e),
371
+ ]
372
+ : [(id, t, e) => isoMemory(id, bScope, t, e)];
373
+ const bGrant = multiNode
374
+ ? nodeGrant({
375
+ principal: principal(bScope),
376
+ read: [
377
+ {
378
+ node: { level: 'user', orgId: 'local', customerId: 'someone-else' },
379
+ projectIds: ['pa'],
380
+ authorAgentId: 'claude-code',
381
+ },
382
+ { node: nodeOf(payments), projectIds: 'all' },
383
+ ],
384
+ })
385
+ : soleGrant(bScope);
262
386
  const ops = [];
263
387
  const aIds = [];
264
388
  const bIds = [];
@@ -278,8 +402,10 @@ function opSequence(seed, steps) {
278
402
  if (kind <= 2 || own.length === 0) {
279
403
  const id = `${who}-${i}`;
280
404
  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 };
405
+ const builds = who === 'A' ? aBuild : bBuild;
406
+ // One builder draws no number, so the pre-level sequences replay exactly.
407
+ const build = builds.length === 1 ? builds[0] : builds[rng.int(builds.length)];
408
+ const m = { ...build(id, text(), emb()), salience: rng.int(10) / 10 };
283
409
  ops.push({ who, run: (s) => s.upsert(grant, m) });
284
410
  }
285
411
  else if (kind === 3) {
@@ -337,9 +463,13 @@ function opSequence(seed, steps) {
337
463
  * order: SQLite FTS5's bm25 uses table-wide term statistics, so another
338
464
  * tenant's text shifts scores (a known side channel; see the PR notes).
339
465
  * Every other result is compared exactly, dense scores included.
466
+ *
467
+ * `multiNode`: A's grant spans its Planet, its agent's Star and a
468
+ * Constellation of another owner; B's spans its own Planet and a sibling
469
+ * Constellation. Same property.
340
470
  */
341
- export async function nonInterference(make, seed, steps) {
342
- const ops = opSequence(seed, steps);
471
+ export async function nonInterference(make, seed, steps, opts = {}) {
472
+ const ops = opSequence(seed, steps, opts.multiNode === true);
343
473
  const observe = async (withB) => {
344
474
  const s = await make();
345
475
  const seen = [];
@@ -1,5 +1,6 @@
1
1
  import { appendAccess } from './activation.js';
2
- import { assertGrant, canRead, canWrite, cursorKey, filterCovers, GrantError, sameScope, } from './grant.js';
2
+ import { canonicalMemory } from './address.js';
3
+ import { assertGrant, cursorKey, grantReads, grantWrites, GrantError, sameRecordAddress, } from './grant.js';
3
4
  import { isCurrent } from './model.js';
4
5
  import { tokenizeLexical as tokens } from './tokenize.js';
5
6
  function cosine(a, b) {
@@ -46,26 +47,32 @@ export function createMemoryStore(opts = {}) {
46
47
  if (m.tier !== 'cold')
47
48
  cold.delete(m.id);
48
49
  }
49
- function scoped(f) {
50
- return [...main.values()].filter((m) => filterCovers(f, m.scope));
50
+ function scoped(grant) {
51
+ return [...main.values()].filter((m) => grantReads(grant, m));
51
52
  }
52
53
  /** The live record if `grant` may read it; `undefined` for unknown and out-of-grant alike. */
53
54
  function readable(grant, id) {
54
55
  const m = main.get(id);
55
- return m !== undefined && canRead(grant, m.scope) ? m : undefined;
56
+ return m !== undefined && grantReads(grant, m) ? m : undefined;
56
57
  }
57
58
  /** The live record if `grant` may change it. */
58
59
  function writable(grant, id) {
59
60
  const m = main.get(id);
60
- return m !== undefined && canWrite(grant, m.scope) ? m : undefined;
61
+ return m !== undefined && grantWrites(grant, m) ? m : undefined;
61
62
  }
62
- /** Every check `upsert` makes, before anything is written. */
63
- function checkWrite(grant, m) {
64
- if (!canWrite(grant, m.scope))
63
+ /**
64
+ * Every check `upsert` makes, before anything is written: a legal address
65
+ * that matches the scope (`RangeError` otherwise), inside `grant.write`,
66
+ * and the same address, author and scope as any record already under the id.
67
+ * Returns the record as stored.
68
+ */
69
+ function checkWrite(grant, m, existing) {
70
+ const c = canonicalMemory(m);
71
+ if (!grantWrites(grant, c))
65
72
  throw new GrantError();
66
- const existing = main.get(m.id);
67
- if (existing !== undefined && !sameScope(existing.scope, m.scope))
73
+ if (existing !== undefined && !sameRecordAddress(existing, c))
68
74
  throw new GrantError();
75
+ return c;
69
76
  }
70
77
  /** True while the live record still matches the snapshot a guarded write was computed from. */
71
78
  function unchanged(live, snap) {
@@ -116,8 +123,7 @@ export function createMemoryStore(opts = {}) {
116
123
  open();
117
124
  assertGrant(grant);
118
125
  const c = structuredClone(m);
119
- checkWrite(grant, c);
120
- put(c);
126
+ put(checkWrite(grant, c, main.get(c.id)));
121
127
  },
122
128
  /** Atomic trivially: checks and clones every record before writing any. */
123
129
  async upsertMany(grant, ms) {
@@ -127,15 +133,13 @@ export function createMemoryStore(opts = {}) {
127
133
  // Later entries may move an id the batch itself created: check against
128
134
  // the batch's own earlier entries as well as the store.
129
135
  const batch = new Map();
136
+ const checked = [];
130
137
  for (const c of cs) {
131
- if (!canWrite(grant, c.scope))
132
- throw new GrantError();
133
- const existing = batch.get(c.id) ?? main.get(c.id);
134
- if (existing !== undefined && !sameScope(existing.scope, c.scope))
135
- throw new GrantError();
136
- batch.set(c.id, c);
138
+ const ok = checkWrite(grant, c, batch.get(c.id) ?? main.get(c.id));
139
+ batch.set(c.id, ok);
140
+ checked.push(ok);
137
141
  }
138
- for (const c of cs)
142
+ for (const c of checked)
139
143
  put(c);
140
144
  },
141
145
  async get(grant, id) {
@@ -164,7 +168,7 @@ export function createMemoryStore(opts = {}) {
164
168
  if (query.size === 0)
165
169
  return [];
166
170
  const scored = [];
167
- for (const m of scoped(grant.read)) {
171
+ for (const m of scoped(grant)) {
168
172
  const fields = m.tier === 'cold' ? [m.stub, ...m.entities] : [m.text, m.stub, ...m.entities];
169
173
  const doc = new Set(tokens(fields.join(' ')));
170
174
  let score = 0;
@@ -180,7 +184,7 @@ export function createMemoryStore(opts = {}) {
180
184
  open();
181
185
  assertGrant(grant);
182
186
  const scored = [];
183
- for (const m of scoped(grant.read)) {
187
+ for (const m of scoped(grant)) {
184
188
  if (m.tier === 'cold' || m.embedding === undefined)
185
189
  continue;
186
190
  if (m.embedding.length !== v.length) {
@@ -204,7 +208,7 @@ export function createMemoryStore(opts = {}) {
204
208
  assertGrant(grant);
205
209
  const validOnly = listOpts.validOnly ?? true;
206
210
  const now = clock();
207
- return scoped(grant.read)
211
+ return scoped(grant)
208
212
  .filter((m) => !validOnly || isCurrent(m, now))
209
213
  .filter((m) => listOpts.tier === undefined || m.tier === listOpts.tier)
210
214
  .filter((m) => listOpts.pinned !== true || m.pinned)
@@ -225,7 +229,7 @@ export function createMemoryStore(opts = {}) {
225
229
  if (readable(grant, id) === undefined)
226
230
  return null;
227
231
  const m = cold.get(id);
228
- return m === undefined || !canRead(grant, m.scope) ? null : structuredClone(m);
232
+ return m === undefined || !grantReads(grant, m) ? null : structuredClone(m);
229
233
  },
230
234
  async setTier(grant, id, tier, snapshot) {
231
235
  open();
package/dist/model.d.ts CHANGED
@@ -1,5 +1,12 @@
1
+ import type { Address, Author, Provenance } from './address.js';
1
2
  export type MemoryType = 'semantic' | 'episodic' | 'procedural' | 'preference';
2
3
  export type Tier = 'hot' | 'warm' | 'cold';
4
+ /**
5
+ * The pre-level view of where a memory lives. For a Planet memory (every
6
+ * memory written before levels) it is the whole story: the Planet is
7
+ * (org, customer, project) and `agentId` is the author. For other levels see
8
+ * `scopeOf` in `address.ts`. Grants are checked against `address` and `author`.
9
+ */
3
10
  export interface Scope {
4
11
  orgId: string;
5
12
  customerId: string;
@@ -38,6 +45,16 @@ export interface MemorySource {
38
45
  export interface Memory {
39
46
  id: string;
40
47
  scope: Scope;
48
+ /**
49
+ * Where the memory lives. Absent: the Planet `scope` names, which is where
50
+ * every memory written before levels lives, so stores return it only for
51
+ * other levels. Read it with `addressOf(m)`.
52
+ */
53
+ address?: Address;
54
+ /** Who wrote it. Absent: the agent `scope.agentId` names. Read it with `authorOf(m)`. */
55
+ author?: Author;
56
+ /** Where a shared copy came from; absent (or null) on ordinary memories. */
57
+ provenance?: Provenance | null;
41
58
  type: MemoryType;
42
59
  text: string;
43
60
  stub: string;
@@ -1,3 +1,4 @@
1
+ import { type Address, type Author, type NodeRef, type Provenance } from './address.js';
1
2
  import type { MemoryStore } from './adapters.js';
2
3
  import { type Grant } from './grant.js';
3
4
  import type { Memory, Scope } from './model.js';
@@ -22,3 +23,14 @@ export declare const TG: Grant;
22
23
  /** The narrowest grant that can read and write a memory at `scope`. */
23
24
  export declare function grantFor(scope: Scope): Grant;
24
25
  export declare function storeConformance(name: string, make: () => Promise<MemoryStore>): void;
26
+ /** One memory per level (and a Planet memory by another agent), each with a distinct embedding. */
27
+ export declare const LEVEL_CASES: {
28
+ name: string;
29
+ address: Address;
30
+ author: Author;
31
+ provenance?: Provenance;
32
+ }[];
33
+ /** A memory at `address` by `author` (scope filled to match). */
34
+ export declare function memoryAt(id: string, address: Address, author: Author, overrides?: Partial<Memory>): Memory;
35
+ /** Read and write every project of `nodes` (no author restriction). */
36
+ export declare function nodesGrant(...nodes: NodeRef[]): Grant;