@alma-harness/testing 0.1.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/LICENSE +202 -0
- package/README.md +53 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +1510 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
// src/session-store-contract.ts
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { toWellFormedDeep } from "@alma-harness/core";
|
|
4
|
+
var ALICE = { org: "org-a", uid: "user-alice" };
|
|
5
|
+
var BOB = { org: "org-a", uid: "user-bob" };
|
|
6
|
+
var OTHER_ORG = { org: "org-b", uid: "user-alice" };
|
|
7
|
+
function text(role, body) {
|
|
8
|
+
return {
|
|
9
|
+
role,
|
|
10
|
+
blocks: [{ type: "text", text: body }],
|
|
11
|
+
meta: { at: "2026-01-01T00:00:00.000Z" }
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function withTools(at) {
|
|
15
|
+
return [
|
|
16
|
+
{ role: "user", blocks: [{ type: "text", text: "check my notes" }], meta: { at } },
|
|
17
|
+
{
|
|
18
|
+
role: "assistant",
|
|
19
|
+
blocks: [
|
|
20
|
+
{ type: "text", text: "one moment" },
|
|
21
|
+
{ type: "tool_call", id: "c1", name: "listNotes", input: {} }
|
|
22
|
+
],
|
|
23
|
+
meta: { at }
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
role: "tool",
|
|
27
|
+
blocks: [{ type: "tool_result", callId: "c1", output: "two notes" }],
|
|
28
|
+
meta: { at }
|
|
29
|
+
},
|
|
30
|
+
{ role: "assistant", blocks: [{ type: "text", text: "you have two" }], meta: { at } }
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
var kinds = (history) => history.map((m) => m.blocks.map((b) => b.type));
|
|
34
|
+
function bodies(history) {
|
|
35
|
+
return history.map((m) => m.blocks[0]?.type === "text" ? m.blocks[0].text : m.blocks[0]);
|
|
36
|
+
}
|
|
37
|
+
function describeSessionStoreContract(name, factory) {
|
|
38
|
+
describe(`SessionStore contract: ${name}`, () => {
|
|
39
|
+
let store;
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
store = await factory.create();
|
|
42
|
+
});
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await factory.destroy?.(store);
|
|
45
|
+
});
|
|
46
|
+
it("round-trips appended messages, preserving content and order", async () => {
|
|
47
|
+
const entries = [text("user", "hello"), text("assistant", "hi there")];
|
|
48
|
+
await store.append(ALICE, "s1", entries);
|
|
49
|
+
const history = await store.load(ALICE, "s1");
|
|
50
|
+
expect(history).toEqual(entries);
|
|
51
|
+
});
|
|
52
|
+
it("round-trips astral-plane text unchanged \u2014 emoji and CJK, not just ASCII", async () => {
|
|
53
|
+
const entries = [
|
|
54
|
+
text("user", "n\xE3o aguento mais \u{1F642}\u{1F1E7}\u{1F1F7} \u2014 \u5BB6\u65CF\u3068\u8A71\u3057\u307E\u3057\u305F"),
|
|
55
|
+
text("assistant", "\u{1D518}\u{1D52B}\u{1D526}\u{1D520}\u{1D52C}\u{1D521}\u{1D522} \u2705")
|
|
56
|
+
];
|
|
57
|
+
await store.append(ALICE, "s1", entries);
|
|
58
|
+
const history = await store.load(ALICE, "s1");
|
|
59
|
+
expect(history).toEqual(entries);
|
|
60
|
+
for (const body of bodies(history)) expect(String(body).isWellFormed()).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it("preserves order across sequential appends (transactional seq)", async () => {
|
|
63
|
+
await store.append(ALICE, "s1", [text("user", "1")]);
|
|
64
|
+
await store.append(ALICE, "s1", [text("assistant", "2"), text("user", "3")]);
|
|
65
|
+
await store.append(ALICE, "s1", [text("assistant", "4")]);
|
|
66
|
+
expect(bodies(await store.load(ALICE, "s1"))).toEqual(["1", "2", "3", "4"]);
|
|
67
|
+
});
|
|
68
|
+
it("resolves an unknown session to an empty history", async () => {
|
|
69
|
+
await expect(store.load(ALICE, "never-created")).resolves.toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
it("isolates sessions by sessionId", async () => {
|
|
72
|
+
await store.append(ALICE, "s1", [text("user", "in s1")]);
|
|
73
|
+
await store.append(ALICE, "s2", [text("user", "in s2")]);
|
|
74
|
+
expect(bodies(await store.load(ALICE, "s1"))).toEqual(["in s1"]);
|
|
75
|
+
expect(bodies(await store.load(ALICE, "s2"))).toEqual(["in s2"]);
|
|
76
|
+
});
|
|
77
|
+
it("isolates scopes by uid and by org, even for identical session ids", async () => {
|
|
78
|
+
await store.append(ALICE, "shared-id", [text("user", "alice's")]);
|
|
79
|
+
await expect(store.load(BOB, "shared-id")).resolves.toEqual([]);
|
|
80
|
+
await expect(store.load(OTHER_ORG, "shared-id")).resolves.toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
it("load with limit returns the most recent messages, still in order", async () => {
|
|
83
|
+
await store.append(ALICE, "s1", [
|
|
84
|
+
text("user", "1"),
|
|
85
|
+
text("assistant", "2"),
|
|
86
|
+
text("user", "3"),
|
|
87
|
+
text("assistant", "4")
|
|
88
|
+
]);
|
|
89
|
+
expect(bodies(await store.load(ALICE, "s1", { limit: 2 }))).toEqual(["3", "4"]);
|
|
90
|
+
});
|
|
91
|
+
it("erase(scope, sessionId) removes only that session", async () => {
|
|
92
|
+
await store.append(ALICE, "doomed", [text("user", "bye")]);
|
|
93
|
+
await store.append(ALICE, "kept", [text("user", "stay")]);
|
|
94
|
+
await store.erase(ALICE, "doomed");
|
|
95
|
+
await expect(store.load(ALICE, "doomed")).resolves.toEqual([]);
|
|
96
|
+
expect(bodies(await store.load(ALICE, "kept"))).toEqual(["stay"]);
|
|
97
|
+
});
|
|
98
|
+
it("erase(scope) purges every session in the scope and nothing outside it", async () => {
|
|
99
|
+
await store.append(ALICE, "s1", [text("user", "a1")]);
|
|
100
|
+
await store.append(ALICE, "s2", [text("user", "a2")]);
|
|
101
|
+
await store.append(BOB, "s1", [text("user", "b1")]);
|
|
102
|
+
await store.erase(ALICE);
|
|
103
|
+
await expect(store.load(ALICE, "s1")).resolves.toEqual([]);
|
|
104
|
+
await expect(store.load(ALICE, "s2")).resolves.toEqual([]);
|
|
105
|
+
expect(bodies(await store.load(BOB, "s1"))).toEqual(["b1"]);
|
|
106
|
+
});
|
|
107
|
+
it("rejects an invalid scope even on no-op calls (empty append, non-positive limit)", async () => {
|
|
108
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
109
|
+
await expect(store.append(bad, "s1", [])).rejects.toThrow();
|
|
110
|
+
await expect(store.load(bad, "s1", { limit: 0 })).rejects.toThrow();
|
|
111
|
+
await expect(store.erase(bad)).rejects.toThrow();
|
|
112
|
+
});
|
|
113
|
+
it("expires a stale session's tool traffic and keeps everything the person saw", async () => {
|
|
114
|
+
await store.append(ALICE, "s1", withTools("2026-01-01T00:00:00.000Z"));
|
|
115
|
+
const report = await store.expireToolTraffic(ALICE, "s1", {
|
|
116
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
117
|
+
});
|
|
118
|
+
expect(report).toEqual({ blocks: 2, messages: 1, expired: true });
|
|
119
|
+
expect(kinds(await store.load(ALICE, "s1"))).toEqual([["text"], ["text"], ["text"]]);
|
|
120
|
+
expect(bodies(await store.load(ALICE, "s1"))).toEqual([
|
|
121
|
+
"check my notes",
|
|
122
|
+
"one moment",
|
|
123
|
+
"you have two"
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
it("refuses an ACTIVE session and touches nothing \u2014 a live prefix must not be rewritten", async () => {
|
|
127
|
+
const entries = withTools("2026-07-01T00:00:00.000Z");
|
|
128
|
+
await store.append(ALICE, "s1", entries);
|
|
129
|
+
const report = await store.expireToolTraffic(ALICE, "s1", {
|
|
130
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
131
|
+
});
|
|
132
|
+
expect(report).toEqual({ blocks: 0, messages: 0, expired: false });
|
|
133
|
+
expect(await store.load(ALICE, "s1")).toEqual(entries);
|
|
134
|
+
});
|
|
135
|
+
it("never leaves a tool_call without its tool_result \u2014 the pairing a provider enforces with a 400", async () => {
|
|
136
|
+
await store.append(ALICE, "s1", withTools("2026-01-01T00:00:00.000Z"));
|
|
137
|
+
await store.expireToolTraffic(ALICE, "s1", {
|
|
138
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
139
|
+
});
|
|
140
|
+
const flat = (await store.load(ALICE, "s1")).flatMap((m) => m.blocks.map((b) => b.type));
|
|
141
|
+
expect(flat).not.toContain("tool_call");
|
|
142
|
+
expect(flat).not.toContain("tool_result");
|
|
143
|
+
});
|
|
144
|
+
it("keeps media blocks \u2014 a pointer the user saw, not tool traffic", async () => {
|
|
145
|
+
await store.append(ALICE, "s1", [
|
|
146
|
+
{
|
|
147
|
+
role: "user",
|
|
148
|
+
blocks: [
|
|
149
|
+
{ type: "media", kind: "image", ref: { uri: "gs://bucket/x.png" } },
|
|
150
|
+
{ type: "tool_call", id: "c1", name: "describe", input: {} }
|
|
151
|
+
],
|
|
152
|
+
meta: { at: "2026-01-01T00:00:00.000Z" }
|
|
153
|
+
}
|
|
154
|
+
]);
|
|
155
|
+
await store.expireToolTraffic(ALICE, "s1", {
|
|
156
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
157
|
+
});
|
|
158
|
+
expect(kinds(await store.load(ALICE, "s1"))).toEqual([["media"]]);
|
|
159
|
+
});
|
|
160
|
+
it("expires only the named session, and validates the scope", async () => {
|
|
161
|
+
await store.append(ALICE, "s1", withTools("2026-01-01T00:00:00.000Z"));
|
|
162
|
+
await store.append(ALICE, "s2", withTools("2026-01-01T00:00:00.000Z"));
|
|
163
|
+
await store.expireToolTraffic(ALICE, "s1", {
|
|
164
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
165
|
+
});
|
|
166
|
+
expect(kinds(await store.load(ALICE, "s2"))).toEqual([
|
|
167
|
+
["text"],
|
|
168
|
+
["text", "tool_call"],
|
|
169
|
+
["tool_result"],
|
|
170
|
+
["text"]
|
|
171
|
+
]);
|
|
172
|
+
await expect(
|
|
173
|
+
store.expireToolTraffic({ org: "../evil", uid: "u" }, "s1", {
|
|
174
|
+
inactiveSince: "2026-06-01T00:00:00.000Z"
|
|
175
|
+
})
|
|
176
|
+
).rejects.toThrow();
|
|
177
|
+
});
|
|
178
|
+
it("round-trips a MediaRef whole, filename included", async () => {
|
|
179
|
+
const ref = {
|
|
180
|
+
uri: "gs://bucket/laudo.pdf",
|
|
181
|
+
contentType: "application/pdf",
|
|
182
|
+
bytes: 20480,
|
|
183
|
+
filename: "laudo \u2014 2026.pdf"
|
|
184
|
+
};
|
|
185
|
+
await store.append(ALICE, "s1", [
|
|
186
|
+
{
|
|
187
|
+
role: "user",
|
|
188
|
+
blocks: [{ type: "media", kind: "document", ref }],
|
|
189
|
+
meta: { at: "2026-01-01T00:00:00.000Z" }
|
|
190
|
+
}
|
|
191
|
+
]);
|
|
192
|
+
const block = (await store.load(ALICE, "s1"))[0]?.blocks[0];
|
|
193
|
+
expect(block?.type === "media" ? block.ref : null).toEqual(ref);
|
|
194
|
+
});
|
|
195
|
+
it("round-trips a MediaRef with no filename \u2014 the field is optional", async () => {
|
|
196
|
+
const ref = { uri: "gs://bucket/x.png" };
|
|
197
|
+
await store.append(ALICE, "s1", [
|
|
198
|
+
{
|
|
199
|
+
role: "user",
|
|
200
|
+
blocks: [{ type: "media", kind: "image", ref }],
|
|
201
|
+
meta: { at: "2026-01-01T00:00:00.000Z" }
|
|
202
|
+
}
|
|
203
|
+
]);
|
|
204
|
+
const block = (await store.load(ALICE, "s1"))[0]?.blocks[0];
|
|
205
|
+
expect(block?.type === "media" ? block.ref : null).toEqual(ref);
|
|
206
|
+
expect(block?.type === "media" ? "filename" in block.ref : true).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
it("expires by the message's own meta.at, not by when the row was written", async () => {
|
|
209
|
+
await store.append(ALICE, "s1", withTools("2025-01-01T00:00:00.000Z"));
|
|
210
|
+
const report = await store.expireToolTraffic(ALICE, "s1", {
|
|
211
|
+
inactiveSince: "2025-06-01T00:00:00.000Z"
|
|
212
|
+
});
|
|
213
|
+
expect(report).toEqual({ blocks: 2, messages: 1, expired: true });
|
|
214
|
+
});
|
|
215
|
+
it("treats a message with no meta.at as ACTIVE \u2014 an age that cannot be established is not old", async () => {
|
|
216
|
+
const undated = {
|
|
217
|
+
role: "assistant",
|
|
218
|
+
blocks: [{ type: "tool_call", id: "c1", name: "x", input: {} }]
|
|
219
|
+
};
|
|
220
|
+
await store.append(ALICE, "s1", [...withTools("2025-01-01T00:00:00.000Z"), undated]);
|
|
221
|
+
const report = await store.expireToolTraffic(ALICE, "s1", {
|
|
222
|
+
inactiveSince: "2025-06-01T00:00:00.000Z"
|
|
223
|
+
});
|
|
224
|
+
expect(report).toEqual({ blocks: 0, messages: 0, expired: false });
|
|
225
|
+
});
|
|
226
|
+
it("refuses malformed UTF-16 identically on every adapter, naming the entry", async () => {
|
|
227
|
+
const lone = `broken \uD83D end`;
|
|
228
|
+
expect(lone.isWellFormed()).toBe(false);
|
|
229
|
+
await expect(
|
|
230
|
+
store.append(ALICE, "s1", [text("user", "fine"), text("assistant", lone)])
|
|
231
|
+
).rejects.toThrow(/entries\[1\]/);
|
|
232
|
+
await expect(store.load(ALICE, "s1")).resolves.toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it("accepts the same value once toWellFormedDeep has run \u2014 the documented remedy works", async () => {
|
|
235
|
+
const repaired = toWellFormedDeep(text("assistant", `broken \uD83D end`));
|
|
236
|
+
await store.append(ALICE, "s1", [repaired]);
|
|
237
|
+
const [loaded] = await store.load(ALICE, "s1");
|
|
238
|
+
expect(loaded).toEqual(repaired);
|
|
239
|
+
expect(String(bodies([loaded])[0]).isWellFormed()).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
it("returns defensive copies \u2014 mutating a loaded history never corrupts the store", async () => {
|
|
242
|
+
await store.append(ALICE, "s1", [text("user", "original")]);
|
|
243
|
+
const first = await store.load(ALICE, "s1");
|
|
244
|
+
first.pop();
|
|
245
|
+
const block = (await store.load(ALICE, "s1"))[0]?.blocks[0];
|
|
246
|
+
if (block?.type === "text") block.text = "mutated";
|
|
247
|
+
expect(bodies(await store.load(ALICE, "s1"))).toEqual(["original"]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/episode-store-contract.ts
|
|
253
|
+
import { afterEach as afterEach2, beforeEach as beforeEach2, describe as describe2, expect as expect2, it as it2 } from "vitest";
|
|
254
|
+
import { deriveEpisodeId, isRecallable } from "@alma-harness/memory";
|
|
255
|
+
var ALICE2 = { org: "org-a", uid: "user-alice" };
|
|
256
|
+
var BOB2 = { org: "org-a", uid: "user-bob" };
|
|
257
|
+
var OTHER_ORG2 = { org: "org-b", uid: "user-alice" };
|
|
258
|
+
var T = {
|
|
259
|
+
old: "2026-01-01T00:00:00.000Z",
|
|
260
|
+
mid: "2026-06-01T00:00:00.000Z",
|
|
261
|
+
recent: "2026-08-01T00:00:00.000Z"
|
|
262
|
+
};
|
|
263
|
+
function summaries(episodes) {
|
|
264
|
+
return episodes.map((e) => e.summary);
|
|
265
|
+
}
|
|
266
|
+
function describeEpisodeStoreContract(name, factory) {
|
|
267
|
+
describe2(`EpisodeStore contract: ${name}`, () => {
|
|
268
|
+
let store;
|
|
269
|
+
beforeEach2(async () => {
|
|
270
|
+
store = await factory.create();
|
|
271
|
+
});
|
|
272
|
+
afterEach2(async () => {
|
|
273
|
+
await factory.destroy?.(store);
|
|
274
|
+
});
|
|
275
|
+
it2("round-trips an appended episode, defaulting importance and state", async () => {
|
|
276
|
+
const stored = await store.append(ALICE2, {
|
|
277
|
+
kind: "conversation",
|
|
278
|
+
summary: "prefers morning appointments",
|
|
279
|
+
at: T.recent,
|
|
280
|
+
source: { sessionId: "s1", turnId: "t1" }
|
|
281
|
+
});
|
|
282
|
+
expect2(stored).toMatchObject({
|
|
283
|
+
kind: "conversation",
|
|
284
|
+
summary: "prefers morning appointments",
|
|
285
|
+
at: T.recent,
|
|
286
|
+
importance: 0.5,
|
|
287
|
+
state: "active",
|
|
288
|
+
source: { sessionId: "s1", turnId: "t1" }
|
|
289
|
+
});
|
|
290
|
+
expect2(isRecallable(stored)).toBe(true);
|
|
291
|
+
const found = await store.get(ALICE2, [stored.id]);
|
|
292
|
+
expect2(found).toEqual([stored]);
|
|
293
|
+
});
|
|
294
|
+
it2("addresses episodes by a deterministic id, so re-extraction overwrites and never duplicates", async () => {
|
|
295
|
+
const input = {
|
|
296
|
+
kind: "note",
|
|
297
|
+
summary: "runs 5k on saturdays",
|
|
298
|
+
source: { sessionId: "s1", turnId: "t1" }
|
|
299
|
+
};
|
|
300
|
+
const first = await store.append(ALICE2, { ...input, at: T.old });
|
|
301
|
+
const second = await store.append(ALICE2, { ...input, at: T.recent });
|
|
302
|
+
expect2(second.id).toBe(first.id);
|
|
303
|
+
expect2(second.id).toBe(deriveEpisodeId(ALICE2, input));
|
|
304
|
+
const { episodes } = await store.query(ALICE2, {});
|
|
305
|
+
expect2(episodes).toHaveLength(1);
|
|
306
|
+
});
|
|
307
|
+
it2("derives different ids for the same content in different scopes", async () => {
|
|
308
|
+
const input = { kind: "note", summary: "same words" };
|
|
309
|
+
const mine = await store.append(ALICE2, input);
|
|
310
|
+
const theirs = await store.append(BOB2, input);
|
|
311
|
+
expect2(mine.id).not.toBe(theirs.id);
|
|
312
|
+
});
|
|
313
|
+
it2("dedupeKey separates two genuinely distinct episodes with identical content", async () => {
|
|
314
|
+
const input = { kind: "note", summary: "checked in", source: { turnId: "t1" } };
|
|
315
|
+
await store.append(ALICE2, { ...input, dedupeKey: "a" });
|
|
316
|
+
await store.append(ALICE2, { ...input, dedupeKey: "b" });
|
|
317
|
+
const { episodes } = await store.query(ALICE2, {});
|
|
318
|
+
expect2(episodes).toHaveLength(2);
|
|
319
|
+
});
|
|
320
|
+
it2("keeps a dedupeKey scoped to its content: reuse in a later turn never overwrites", async () => {
|
|
321
|
+
const first = await store.append(ALICE2, {
|
|
322
|
+
kind: "note",
|
|
323
|
+
summary: "chose the morning slot",
|
|
324
|
+
source: { turnId: "t1" },
|
|
325
|
+
dedupeKey: "choice"
|
|
326
|
+
});
|
|
327
|
+
const second = await store.append(ALICE2, {
|
|
328
|
+
kind: "note",
|
|
329
|
+
summary: "chose the evening slot",
|
|
330
|
+
source: { turnId: "t2" },
|
|
331
|
+
dedupeKey: "choice"
|
|
332
|
+
});
|
|
333
|
+
expect2(second.id).not.toBe(first.id);
|
|
334
|
+
expect2((await store.get(ALICE2, [first.id]))[0]).toMatchObject({
|
|
335
|
+
summary: "chose the morning slot"
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
it2("normalizes timestamps to canonical UTC, so backends never disagree", async () => {
|
|
339
|
+
const stored = await store.append(ALICE2, {
|
|
340
|
+
kind: "note",
|
|
341
|
+
summary: "written with an offset",
|
|
342
|
+
at: "2026-08-01T02:00:00+02:00"
|
|
343
|
+
});
|
|
344
|
+
expect2(stored.at).toBe("2026-08-01T00:00:00.000Z");
|
|
345
|
+
expect2((await store.get(ALICE2, [stored.id]))[0]?.at).toBe("2026-08-01T00:00:00.000Z");
|
|
346
|
+
});
|
|
347
|
+
it2("isolates scopes by uid and by org", async () => {
|
|
348
|
+
await store.append(ALICE2, { kind: "note", summary: "alice's memory" });
|
|
349
|
+
await expect2(store.query(BOB2, {})).resolves.toMatchObject({ episodes: [] });
|
|
350
|
+
await expect2(store.query(OTHER_ORG2, {})).resolves.toMatchObject({ episodes: [] });
|
|
351
|
+
});
|
|
352
|
+
it2("filters by kind and by time range", async () => {
|
|
353
|
+
await store.append(ALICE2, { kind: "note", summary: "note one", at: T.old });
|
|
354
|
+
await store.append(ALICE2, { kind: "observation", summary: "observed thing", at: T.mid });
|
|
355
|
+
await store.append(ALICE2, { kind: "note", summary: "note two", at: T.recent });
|
|
356
|
+
const byKind = await store.query(ALICE2, { kinds: ["observation"] });
|
|
357
|
+
expect2(summaries(byKind.episodes)).toEqual(["observed thing"]);
|
|
358
|
+
const byRange = await store.query(ALICE2, { since: T.mid, until: T.recent });
|
|
359
|
+
expect2(summaries(byRange.episodes).sort()).toEqual(["note two", "observed thing"]);
|
|
360
|
+
});
|
|
361
|
+
it2("ranks a term match above a non-match, and excludes non-matches entirely", async () => {
|
|
362
|
+
await store.append(ALICE2, { kind: "note", summary: "loves kayaking", at: T.old });
|
|
363
|
+
await store.append(ALICE2, { kind: "note", summary: "dislikes crowds", at: T.recent });
|
|
364
|
+
const { episodes } = await store.query(ALICE2, { text: "kayaking" });
|
|
365
|
+
expect2(summaries(episodes)).toEqual(["loves kayaking"]);
|
|
366
|
+
});
|
|
367
|
+
it2("finds a term deep in a long summary, past the query tokenizer's cap", async () => {
|
|
368
|
+
const filler = Array.from({ length: 40 }, (_, i) => `filler${i}`).join(" ");
|
|
369
|
+
await store.append(ALICE2, { kind: "note", summary: `${filler} chocolate` });
|
|
370
|
+
const { episodes } = await store.query(ALICE2, { text: "chocolate" });
|
|
371
|
+
expect2(episodes).toHaveLength(1);
|
|
372
|
+
});
|
|
373
|
+
it2("matches case-insensitively beyond ASCII, whatever locale the backend runs under", async () => {
|
|
374
|
+
await store.append(ALICE2, { kind: "note", summary: "Adora CAF\xC9 da manh\xE3" });
|
|
375
|
+
const { episodes } = await store.query(ALICE2, { text: "caf\xE9" });
|
|
376
|
+
expect2(summaries(episodes)).toEqual(["Adora CAF\xC9 da manh\xE3"]);
|
|
377
|
+
});
|
|
378
|
+
it2("breaks a ranking tie toward the more recent episode", async () => {
|
|
379
|
+
await store.append(ALICE2, { kind: "note", summary: "coffee order", at: T.old });
|
|
380
|
+
await store.append(ALICE2, { kind: "note", summary: "coffee order", dedupeKey: "b", at: T.recent });
|
|
381
|
+
const { episodes } = await store.query(ALICE2, { text: "coffee" });
|
|
382
|
+
expect2(episodes.map((e) => e.at)).toEqual([T.recent, T.old]);
|
|
383
|
+
});
|
|
384
|
+
it2("ranks higher importance above lower at equal overlap and age", async () => {
|
|
385
|
+
await store.append(ALICE2, {
|
|
386
|
+
kind: "note",
|
|
387
|
+
summary: "allergy penicillin",
|
|
388
|
+
dedupeKey: "low",
|
|
389
|
+
at: T.mid,
|
|
390
|
+
importance: 0.1
|
|
391
|
+
});
|
|
392
|
+
await store.append(ALICE2, {
|
|
393
|
+
kind: "note",
|
|
394
|
+
summary: "allergy penicillin",
|
|
395
|
+
dedupeKey: "high",
|
|
396
|
+
at: T.mid,
|
|
397
|
+
importance: 0.9
|
|
398
|
+
});
|
|
399
|
+
const { episodes } = await store.query(ALICE2, { text: "allergy" });
|
|
400
|
+
expect2(episodes.map((e) => e.importance)).toEqual([0.9, 0.1]);
|
|
401
|
+
});
|
|
402
|
+
it2("caps the read at the budget and says it truncated", async () => {
|
|
403
|
+
for (const n of ["one", "two", "three"]) {
|
|
404
|
+
await store.append(ALICE2, { kind: "note", summary: `budget ${n}`, dedupeKey: n, at: T.mid });
|
|
405
|
+
}
|
|
406
|
+
const capped = await store.query(ALICE2, { text: "budget", budget: { maxItems: 1 } });
|
|
407
|
+
expect2(capped.episodes).toHaveLength(1);
|
|
408
|
+
expect2(capped.truncated).toBe(true);
|
|
409
|
+
const uncapped = await store.query(ALICE2, { text: "budget" });
|
|
410
|
+
expect2(uncapped.episodes).toHaveLength(3);
|
|
411
|
+
expect2(uncapped.truncated).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
it2("drops whole items rather than overflowing a character budget", async () => {
|
|
414
|
+
await store.append(ALICE2, { kind: "note", summary: "x".repeat(30), dedupeKey: "big", at: T.mid });
|
|
415
|
+
const result = await store.query(ALICE2, { budget: { maxChars: 10 } });
|
|
416
|
+
expect2(result.episodes).toEqual([]);
|
|
417
|
+
expect2(result.truncated).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
it2("resolves a non-positive limit to an empty result", async () => {
|
|
420
|
+
await store.append(ALICE2, { kind: "note", summary: "present" });
|
|
421
|
+
await expect2(store.query(ALICE2, { limit: 0 })).resolves.toMatchObject({ episodes: [] });
|
|
422
|
+
});
|
|
423
|
+
it2("archives an episode out of hot recall while keeping its content intact", async () => {
|
|
424
|
+
const ep = await store.append(ALICE2, { kind: "note", summary: "seasonal detail", at: T.mid });
|
|
425
|
+
await expect2(store.archive(ALICE2, [ep.id])).resolves.toBe(1);
|
|
426
|
+
const hot = await store.query(ALICE2, { text: "seasonal" });
|
|
427
|
+
expect2(hot.episodes).toEqual([]);
|
|
428
|
+
const cold = await store.query(ALICE2, { text: "seasonal", includeArchived: true });
|
|
429
|
+
expect2(summaries(cold.episodes)).toEqual(["seasonal detail"]);
|
|
430
|
+
expect2(isRecallable(cold.episodes[0])).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
it2("keeps an archived episode archived when it is re-appended", async () => {
|
|
433
|
+
const input = { kind: "note", summary: "seasonal detail", source: { turnId: "t1" } };
|
|
434
|
+
const ep = await store.append(ALICE2, input);
|
|
435
|
+
await store.archive(ALICE2, [ep.id]);
|
|
436
|
+
const reappended = await store.append(ALICE2, input);
|
|
437
|
+
expect2(reappended.state).toBe("archived");
|
|
438
|
+
});
|
|
439
|
+
it2("tombstones an episode: content gone, position kept, never recalled again", async () => {
|
|
440
|
+
const ep = await store.append(ALICE2, {
|
|
441
|
+
kind: "note",
|
|
442
|
+
summary: "sensitive detail",
|
|
443
|
+
at: T.mid,
|
|
444
|
+
source: { sessionId: "s1" }
|
|
445
|
+
});
|
|
446
|
+
const result = await store.tombstone(ALICE2, { kind: "episodes", episodeIds: [ep.id] }, T.recent);
|
|
447
|
+
expect2(result.episodeIds).toEqual([ep.id]);
|
|
448
|
+
const [stored] = await store.get(ALICE2, [ep.id]);
|
|
449
|
+
expect2(stored).toMatchObject({
|
|
450
|
+
id: ep.id,
|
|
451
|
+
kind: "",
|
|
452
|
+
// the label goes too — nothing enforces that it is not content
|
|
453
|
+
summary: "",
|
|
454
|
+
state: "tombstoned",
|
|
455
|
+
erasedAt: T.recent
|
|
456
|
+
});
|
|
457
|
+
expect2(isRecallable(stored)).toBe(false);
|
|
458
|
+
await expect2(store.query(ALICE2, { text: "sensitive" })).resolves.toMatchObject({
|
|
459
|
+
episodes: []
|
|
460
|
+
});
|
|
461
|
+
await expect2(
|
|
462
|
+
store.query(ALICE2, { text: "sensitive", includeArchived: true })
|
|
463
|
+
).resolves.toMatchObject({ episodes: [] });
|
|
464
|
+
});
|
|
465
|
+
it2("never resurrects a tombstoned episode on re-append", async () => {
|
|
466
|
+
const input = { kind: "note", summary: "erased content", source: { turnId: "t1" } };
|
|
467
|
+
const ep = await store.append(ALICE2, input);
|
|
468
|
+
await store.tombstone(ALICE2, { kind: "episodes", episodeIds: [ep.id] }, T.recent);
|
|
469
|
+
const reappended = await store.append(ALICE2, input);
|
|
470
|
+
expect2(reappended.state).toBe("tombstoned");
|
|
471
|
+
expect2(reappended.summary).toBe("");
|
|
472
|
+
await expect2(store.query(ALICE2, { text: "erased" })).resolves.toMatchObject({ episodes: [] });
|
|
473
|
+
});
|
|
474
|
+
it2("refuses to archive a tombstoned episode", async () => {
|
|
475
|
+
const ep = await store.append(ALICE2, { kind: "note", summary: "erased" });
|
|
476
|
+
await store.tombstone(ALICE2, { kind: "episodes", episodeIds: [ep.id] }, T.recent);
|
|
477
|
+
await expect2(store.archive(ALICE2, [ep.id])).resolves.toBe(0);
|
|
478
|
+
expect2((await store.get(ALICE2, [ep.id]))[0]).toMatchObject({ state: "tombstoned" });
|
|
479
|
+
});
|
|
480
|
+
it2("tombstones every episode of a session, and nothing outside it", async () => {
|
|
481
|
+
const doomed = await store.append(ALICE2, {
|
|
482
|
+
kind: "note",
|
|
483
|
+
summary: "in the doomed session",
|
|
484
|
+
source: { sessionId: "doomed" }
|
|
485
|
+
});
|
|
486
|
+
const kept = await store.append(ALICE2, {
|
|
487
|
+
kind: "note",
|
|
488
|
+
summary: "in another session",
|
|
489
|
+
source: { sessionId: "kept" }
|
|
490
|
+
});
|
|
491
|
+
const result = await store.tombstone(ALICE2, { kind: "sessions", sessionIds: ["doomed"] }, T.recent);
|
|
492
|
+
expect2(result.episodeIds).toEqual([doomed.id]);
|
|
493
|
+
expect2((await store.get(ALICE2, [kept.id]))[0]).toMatchObject({ state: "active" });
|
|
494
|
+
});
|
|
495
|
+
it2("tombstones the whole scope on the 'all' selector, and nothing outside the scope", async () => {
|
|
496
|
+
const mine = await store.append(ALICE2, { kind: "note", summary: "mine" });
|
|
497
|
+
const theirs = await store.append(BOB2, { kind: "note", summary: "theirs" });
|
|
498
|
+
const result = await store.tombstone(ALICE2, { kind: "all" }, T.recent);
|
|
499
|
+
expect2(result.episodeIds).toEqual([mine.id]);
|
|
500
|
+
expect2((await store.get(BOB2, [theirs.id]))[0]).toMatchObject({ state: "active" });
|
|
501
|
+
});
|
|
502
|
+
it2("reports every copy surface it declares, so erasure can be proven", async () => {
|
|
503
|
+
await store.append(ALICE2, { kind: "note", summary: "anything" });
|
|
504
|
+
const result = await store.tombstone(ALICE2, { kind: "all" }, T.recent);
|
|
505
|
+
expect2(store.copySurfaces.length).toBeGreaterThan(0);
|
|
506
|
+
for (const surface of store.copySurfaces) {
|
|
507
|
+
expect2(result.surfaces).toContain(surface.name);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
it2("cannot be pinned to the top of every ranking by a future timestamp", async () => {
|
|
511
|
+
await store.append(ALICE2, { kind: "note", summary: "honest coffee note", at: T.recent });
|
|
512
|
+
await store.append(ALICE2, {
|
|
513
|
+
kind: "note",
|
|
514
|
+
summary: "coffee spam",
|
|
515
|
+
dedupeKey: "future",
|
|
516
|
+
at: "9999-01-01T00:00:00.000Z"
|
|
517
|
+
});
|
|
518
|
+
const { episodes } = await store.query(ALICE2, { text: "coffee" });
|
|
519
|
+
expect2(episodes[0]?.summary).toBe("honest coffee note");
|
|
520
|
+
});
|
|
521
|
+
it2("returns nothing for a search whose text carries no usable term", async () => {
|
|
522
|
+
await store.append(ALICE2, { kind: "note", summary: "private detail", at: T.recent });
|
|
523
|
+
await expect2(store.query(ALICE2, { text: "a" })).resolves.toMatchObject({ episodes: [] });
|
|
524
|
+
await expect2(store.query(ALICE2, { text: "???" })).resolves.toMatchObject({ episodes: [] });
|
|
525
|
+
});
|
|
526
|
+
it2("normalizes query bounds, so a bound without a timezone means one thing", async () => {
|
|
527
|
+
await store.append(ALICE2, { kind: "note", summary: "in range", at: "2026-06-15T00:00:00.000Z" });
|
|
528
|
+
const found = await store.query(ALICE2, { since: "2026-06-01T00:00:00Z" });
|
|
529
|
+
expect2(summaries(found.episodes)).toEqual(["in range"]);
|
|
530
|
+
await expect2(store.query(ALICE2, { since: "not a date" })).rejects.toThrow();
|
|
531
|
+
});
|
|
532
|
+
it2("bounds every caller-controlled size", async () => {
|
|
533
|
+
await expect2(
|
|
534
|
+
store.append(ALICE2, { kind: "note", summary: "x".repeat(1e5) })
|
|
535
|
+
).rejects.toThrow();
|
|
536
|
+
await expect2(store.append(ALICE2, { kind: "NOT A SLUG", summary: "x" })).rejects.toThrow();
|
|
537
|
+
await expect2(store.append(ALICE2, { kind: "note", summary: "x", importance: 42 })).rejects.toThrow();
|
|
538
|
+
await expect2(store.query(ALICE2, { limit: 1e7 })).rejects.toThrow();
|
|
539
|
+
await expect2(store.query(ALICE2, { limit: 1.5 })).rejects.toThrow();
|
|
540
|
+
});
|
|
541
|
+
it2("rejects content carrying control characters no backend can store alike", async () => {
|
|
542
|
+
await expect2(
|
|
543
|
+
store.append(ALICE2, { kind: "note", summary: "before\0after" })
|
|
544
|
+
).rejects.toThrow();
|
|
545
|
+
});
|
|
546
|
+
it2("rejects a lone surrogate no backend can store alike", async () => {
|
|
547
|
+
await expect2(
|
|
548
|
+
store.append(ALICE2, { kind: "note", summary: "before\uD83Dafter" })
|
|
549
|
+
).rejects.toThrow();
|
|
550
|
+
});
|
|
551
|
+
it2("keeps a dedupeKey from ever colliding with a content-derived id", async () => {
|
|
552
|
+
const target = await store.append(ALICE2, {
|
|
553
|
+
kind: "clinical",
|
|
554
|
+
summary: "severe penicillin allergy",
|
|
555
|
+
source: { sessionId: "s1", turnId: "t1" }
|
|
556
|
+
});
|
|
557
|
+
const forged = await store.append(ALICE2, {
|
|
558
|
+
kind: "note",
|
|
559
|
+
summary: "no known allergies",
|
|
560
|
+
dedupeKey: JSON.stringify(["clinical", "severe penicillin allergy", "s1", "t1"])
|
|
561
|
+
});
|
|
562
|
+
expect2(forged.id).not.toBe(target.id);
|
|
563
|
+
expect2((await store.get(ALICE2, [target.id]))[0]).toMatchObject({
|
|
564
|
+
summary: "severe penicillin allergy"
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
it2("rejects an invalid scope even on no-op calls", async () => {
|
|
568
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
569
|
+
await expect2(store.append(bad, { kind: "note", summary: "x" })).rejects.toThrow();
|
|
570
|
+
await expect2(store.query(bad, { limit: 0 })).rejects.toThrow();
|
|
571
|
+
await expect2(store.get(bad, [])).rejects.toThrow();
|
|
572
|
+
await expect2(store.tombstone(bad, { kind: "episodes", episodeIds: [] }, T.recent)).rejects.toThrow();
|
|
573
|
+
await expect2(store.archive(bad, [])).rejects.toThrow();
|
|
574
|
+
});
|
|
575
|
+
it2("returns defensive copies \u2014 mutating a result never corrupts the store", async () => {
|
|
576
|
+
const ep = await store.append(ALICE2, { kind: "note", summary: "original", at: T.mid });
|
|
577
|
+
const first = await store.query(ALICE2, {});
|
|
578
|
+
const mutable = first.episodes[0];
|
|
579
|
+
mutable.summary = "mutated";
|
|
580
|
+
const [reread] = await store.get(ALICE2, [ep.id]);
|
|
581
|
+
expect2(reread?.summary).toBe("original");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/profile-store-contract.ts
|
|
587
|
+
import { afterEach as afterEach3, beforeEach as beforeEach3, describe as describe3, expect as expect3, it as it3 } from "vitest";
|
|
588
|
+
import { isCurrentFact } from "@alma-harness/memory";
|
|
589
|
+
var ALICE3 = { org: "org-a", uid: "user-alice" };
|
|
590
|
+
var BOB3 = { org: "org-a", uid: "user-bob" };
|
|
591
|
+
var T2 = {
|
|
592
|
+
t1: "2026-06-01T00:00:00.000Z",
|
|
593
|
+
t2: "2026-07-01T00:00:00.000Z",
|
|
594
|
+
t3: "2026-08-01T00:00:00.000Z"
|
|
595
|
+
};
|
|
596
|
+
function byKey(facts, key2) {
|
|
597
|
+
return facts.filter((f) => f.key === key2);
|
|
598
|
+
}
|
|
599
|
+
function describeProfileStoreContract(name, factory) {
|
|
600
|
+
describe3(`ProfileStore contract: ${name}`, () => {
|
|
601
|
+
let store;
|
|
602
|
+
beforeEach3(async () => {
|
|
603
|
+
store = await factory.create();
|
|
604
|
+
});
|
|
605
|
+
afterEach3(async () => {
|
|
606
|
+
await factory.destroy?.(store);
|
|
607
|
+
});
|
|
608
|
+
it3("resolves an empty scope to an empty profile", async () => {
|
|
609
|
+
await expect3(store.get(ALICE3)).resolves.toMatchObject({ facts: [], truncated: false });
|
|
610
|
+
});
|
|
611
|
+
it3("inserts a first observation as the current fact", async () => {
|
|
612
|
+
const [result] = await store.observe(ALICE3, [
|
|
613
|
+
{ key: "diet.preference", value: "vegetarian", confidence: 0.8, at: T2.t1 }
|
|
614
|
+
]);
|
|
615
|
+
expect3(result).toMatchObject({ key: "diet.preference", outcome: "inserted" });
|
|
616
|
+
const { facts } = await store.get(ALICE3);
|
|
617
|
+
expect3(facts).toHaveLength(1);
|
|
618
|
+
expect3(facts[0]).toMatchObject({
|
|
619
|
+
key: "diet.preference",
|
|
620
|
+
value: "vegetarian",
|
|
621
|
+
confidence: 0.8,
|
|
622
|
+
observedAt: T2.t1,
|
|
623
|
+
lastSeenAt: T2.t1
|
|
624
|
+
});
|
|
625
|
+
expect3(isCurrentFact(facts[0])).toBe(true);
|
|
626
|
+
});
|
|
627
|
+
it3("refreshes a re-observed value instead of versioning it, unioning provenance", async () => {
|
|
628
|
+
await store.observe(ALICE3, [
|
|
629
|
+
{ key: "city", value: "lisbon", confidence: 0.6, sourceEpisodeIds: ["ep-1"], at: T2.t1 }
|
|
630
|
+
]);
|
|
631
|
+
const [result] = await store.observe(ALICE3, [
|
|
632
|
+
{ key: "city", value: "lisbon", confidence: 0.9, sourceEpisodeIds: ["ep-2"], at: T2.t2 }
|
|
633
|
+
]);
|
|
634
|
+
expect3(result?.outcome).toBe("refreshed");
|
|
635
|
+
const { facts } = await store.get(ALICE3, { includeHistory: true });
|
|
636
|
+
expect3(facts).toHaveLength(1);
|
|
637
|
+
expect3(facts[0]).toMatchObject({
|
|
638
|
+
value: "lisbon",
|
|
639
|
+
confidence: 0.9,
|
|
640
|
+
// corroboration raises confidence, never lowers it
|
|
641
|
+
observedAt: T2.t1,
|
|
642
|
+
lastSeenAt: T2.t2
|
|
643
|
+
});
|
|
644
|
+
expect3([...facts[0].sourceEpisodeIds].sort()).toEqual(["ep-1", "ep-2"]);
|
|
645
|
+
});
|
|
646
|
+
it3("supersedes the current value when a different one arrives at no less confidence", async () => {
|
|
647
|
+
await store.observe(ALICE3, [{ key: "city", value: "lisbon", confidence: 0.6, at: T2.t1 }]);
|
|
648
|
+
const [result] = await store.observe(ALICE3, [
|
|
649
|
+
{ key: "city", value: "porto", confidence: 0.7, at: T2.t2 }
|
|
650
|
+
]);
|
|
651
|
+
expect3(result?.outcome).toBe("superseded");
|
|
652
|
+
const current = await store.get(ALICE3);
|
|
653
|
+
expect3(current.facts).toHaveLength(1);
|
|
654
|
+
expect3(current.facts[0]).toMatchObject({ value: "porto" });
|
|
655
|
+
const history = await store.get(ALICE3, { includeHistory: true });
|
|
656
|
+
const closed = history.facts.find((f) => f.value === "lisbon");
|
|
657
|
+
expect3(closed).toMatchObject({ supersededAt: T2.t2, supersededBy: result?.factId });
|
|
658
|
+
});
|
|
659
|
+
it3("records a lower-confidence contradiction already closed and keeps the current fact", async () => {
|
|
660
|
+
await store.observe(ALICE3, [{ key: "city", value: "lisbon", confidence: 0.9, at: T2.t1 }]);
|
|
661
|
+
const [result] = await store.observe(ALICE3, [
|
|
662
|
+
{ key: "city", value: "madrid", confidence: 0.3, at: T2.t2 }
|
|
663
|
+
]);
|
|
664
|
+
expect3(result?.outcome).toBe("conflict");
|
|
665
|
+
expect3(result?.detail).toBeTruthy();
|
|
666
|
+
const current = await store.get(ALICE3);
|
|
667
|
+
expect3(current.facts).toHaveLength(1);
|
|
668
|
+
expect3(current.facts[0]).toMatchObject({ value: "lisbon" });
|
|
669
|
+
const history = await store.get(ALICE3, { includeHistory: true });
|
|
670
|
+
const rejected = history.facts.find((f) => f.value === "madrid");
|
|
671
|
+
expect3(rejected).toMatchObject({ supersededAt: T2.t2 });
|
|
672
|
+
expect3(isCurrentFact(rejected)).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
it3("never resurrects a closed version when an old value returns", async () => {
|
|
675
|
+
await store.observe(ALICE3, [{ key: "city", value: "lisbon", confidence: 0.5, at: T2.t1 }]);
|
|
676
|
+
await store.observe(ALICE3, [{ key: "city", value: "porto", confidence: 0.6, at: T2.t2 }]);
|
|
677
|
+
await store.observe(ALICE3, [{ key: "city", value: "lisbon", confidence: 0.7, at: T2.t3 }]);
|
|
678
|
+
const current = await store.get(ALICE3);
|
|
679
|
+
expect3(current.facts).toHaveLength(1);
|
|
680
|
+
expect3(current.facts[0]).toMatchObject({ value: "lisbon", observedAt: T2.t3 });
|
|
681
|
+
const history = await store.get(ALICE3, { includeHistory: true });
|
|
682
|
+
expect3(byKey(history.facts, "city")).toHaveLength(3);
|
|
683
|
+
const closedLisbon = history.facts.find((f) => f.value === "lisbon" && f.observedAt === T2.t1);
|
|
684
|
+
expect3(closedLisbon?.supersededAt).toBe(T2.t2);
|
|
685
|
+
});
|
|
686
|
+
it3("refuses to write a protected profile key through the model path, writing nothing", async () => {
|
|
687
|
+
const results = await store.observe(ALICE3, [
|
|
688
|
+
{ key: "identity.uid", value: "user-bob", confidence: 1, at: T2.t1 },
|
|
689
|
+
{ key: "hobby", value: "climbing", confidence: 0.5, at: T2.t1 }
|
|
690
|
+
]);
|
|
691
|
+
expect3(results[0]).toMatchObject({ key: "identity.uid", outcome: "refused" });
|
|
692
|
+
expect3(results[0]?.factId).toBeUndefined();
|
|
693
|
+
expect3(results[1]?.outcome).toBe("inserted");
|
|
694
|
+
const { facts } = await store.get(ALICE3, { includeHistory: true });
|
|
695
|
+
expect3(byKey(facts, "identity.uid")).toEqual([]);
|
|
696
|
+
expect3(byKey(facts, "hobby")).toHaveLength(1);
|
|
697
|
+
});
|
|
698
|
+
it3("lets the product write protected keys, and lets them outrank a confident extraction", async () => {
|
|
699
|
+
await store.observe(ALICE3, [{ key: "name", value: "wrong", confidence: 0.99, at: T2.t1 }]);
|
|
700
|
+
const results = await store.setProtected(ALICE3, [
|
|
701
|
+
{ key: "identity.uid", value: "user-alice", at: T2.t2 },
|
|
702
|
+
{ key: "name", value: "right", confidence: 0.1, at: T2.t2 }
|
|
703
|
+
]);
|
|
704
|
+
expect3(results.map((r) => r.outcome)).toEqual(["inserted", "superseded"]);
|
|
705
|
+
const { facts } = await store.get(ALICE3);
|
|
706
|
+
expect3(facts.find((f) => f.key === "identity.uid")).toMatchObject({ value: "user-alice" });
|
|
707
|
+
expect3(facts.find((f) => f.key === "name")).toMatchObject({ value: "right" });
|
|
708
|
+
});
|
|
709
|
+
it3("caps the read at the budget, keeping the most trusted facts", async () => {
|
|
710
|
+
await store.observe(ALICE3, [
|
|
711
|
+
{ key: "a", value: "low", confidence: 0.2, at: T2.t1 },
|
|
712
|
+
{ key: "b", value: "high", confidence: 0.9, at: T2.t1 },
|
|
713
|
+
{ key: "c", value: "mid", confidence: 0.5, at: T2.t1 }
|
|
714
|
+
]);
|
|
715
|
+
const capped = await store.get(ALICE3, { budget: { maxItems: 2 } });
|
|
716
|
+
expect3(capped.facts.map((f) => f.key)).toEqual(["b", "c"]);
|
|
717
|
+
expect3(capped.truncated).toBe(true);
|
|
718
|
+
const uncapped = await store.get(ALICE3);
|
|
719
|
+
expect3(uncapped.facts).toHaveLength(3);
|
|
720
|
+
expect3(uncapped.truncated).toBe(false);
|
|
721
|
+
});
|
|
722
|
+
it3("invalidates every fact whose provenance intersects the erased episodes", async () => {
|
|
723
|
+
await store.observe(ALICE3, [
|
|
724
|
+
{ key: "derived", value: "from ep-1", sourceEpisodeIds: ["ep-1"], at: T2.t1 },
|
|
725
|
+
{ key: "independent", value: "from ep-2", sourceEpisodeIds: ["ep-2"], at: T2.t1 }
|
|
726
|
+
]);
|
|
727
|
+
const result = await store.invalidateBySource(ALICE3, ["ep-1"], T2.t3);
|
|
728
|
+
expect3(result.invalidated).toBe(1);
|
|
729
|
+
const { facts } = await store.get(ALICE3);
|
|
730
|
+
expect3(facts.map((f) => f.key)).toEqual(["independent"]);
|
|
731
|
+
const history = await store.get(ALICE3, { includeHistory: true });
|
|
732
|
+
const erased = history.facts.filter((f) => f.invalidatedAt !== void 0);
|
|
733
|
+
expect3(erased).toHaveLength(1);
|
|
734
|
+
expect3(erased[0]).toMatchObject({ key: "", value: "", invalidatedAt: T2.t3 });
|
|
735
|
+
});
|
|
736
|
+
it3("invalidates the whole scope on 'all', without walking provenance", async () => {
|
|
737
|
+
await store.observe(ALICE3, [{ key: "unsourced", value: "no provenance", at: T2.t1 }]);
|
|
738
|
+
await store.observe(BOB3, [{ key: "theirs", value: "untouched", at: T2.t1 }]);
|
|
739
|
+
const result = await store.invalidateBySource(ALICE3, "all", T2.t3);
|
|
740
|
+
expect3(result.invalidated).toBe(1);
|
|
741
|
+
await expect3(store.get(ALICE3)).resolves.toMatchObject({ facts: [] });
|
|
742
|
+
const theirs = await store.get(BOB3);
|
|
743
|
+
expect3(theirs.facts).toHaveLength(1);
|
|
744
|
+
});
|
|
745
|
+
it3("reports every copy surface it declares, so erasure can be proven", async () => {
|
|
746
|
+
await store.observe(ALICE3, [{ key: "any", value: "thing", at: T2.t1 }]);
|
|
747
|
+
const result = await store.invalidateBySource(ALICE3, "all", T2.t3);
|
|
748
|
+
expect3(store.copySurfaces.length).toBeGreaterThan(0);
|
|
749
|
+
for (const surface of store.copySurfaces) {
|
|
750
|
+
expect3(result.surfaces).toContain(surface.name);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
it3("normalizes timestamps to canonical UTC, so backends never disagree", async () => {
|
|
754
|
+
await store.observe(ALICE3, [{ key: "k", value: "v", at: "2026-07-01T02:00:00+02:00" }]);
|
|
755
|
+
const { facts } = await store.get(ALICE3);
|
|
756
|
+
expect3(facts[0]).toMatchObject({
|
|
757
|
+
observedAt: "2026-07-01T00:00:00.000Z",
|
|
758
|
+
lastSeenAt: "2026-07-01T00:00:00.000Z"
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
it3("isolates scopes by uid", async () => {
|
|
762
|
+
await store.observe(ALICE3, [{ key: "secret", value: "alice's", at: T2.t1 }]);
|
|
763
|
+
await expect3(store.get(BOB3)).resolves.toMatchObject({ facts: [] });
|
|
764
|
+
});
|
|
765
|
+
it3("isolates scopes by org, even for the same uid", async () => {
|
|
766
|
+
await store.observe(ALICE3, [{ key: "secret", value: "alice's", at: T2.t1 }]);
|
|
767
|
+
await expect3(store.get({ org: "org-b", uid: ALICE3.uid })).resolves.toMatchObject({
|
|
768
|
+
facts: []
|
|
769
|
+
});
|
|
770
|
+
const result = await store.invalidateBySource({ org: "org-b", uid: ALICE3.uid }, "all", T2.t2);
|
|
771
|
+
expect3(result.invalidated).toBe(0);
|
|
772
|
+
await expect3(store.get(ALICE3)).resolves.toMatchObject({
|
|
773
|
+
facts: [expect3.objectContaining({ key: "secret" })]
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
it3("leaves exactly one current version under concurrent observations of one key", async () => {
|
|
777
|
+
await Promise.all(
|
|
778
|
+
["a", "b", "c", "d", "e"].map(
|
|
779
|
+
(value, i) => store.observe(ALICE3, [
|
|
780
|
+
{ key: "contended", value, confidence: 0.5, at: `2026-08-0${i + 1}T00:00:00.000Z` }
|
|
781
|
+
])
|
|
782
|
+
)
|
|
783
|
+
);
|
|
784
|
+
const { facts } = await store.get(ALICE3);
|
|
785
|
+
expect3(byKey(facts, "contended")).toHaveLength(1);
|
|
786
|
+
});
|
|
787
|
+
it3("survives concurrent multi-key observations naming the keys in opposite orders", async () => {
|
|
788
|
+
const keys = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8"];
|
|
789
|
+
const forward = keys.map((key2) => ({ key: key2, value: `${key2}-forward`, at: T2.t1 }));
|
|
790
|
+
const reverse = [...keys].reverse().map((key2) => ({ key: key2, value: `${key2}-reverse`, at: T2.t2 }));
|
|
791
|
+
await Promise.all([store.observe(ALICE3, forward), store.observe(ALICE3, reverse)]);
|
|
792
|
+
const { facts } = await store.get(ALICE3);
|
|
793
|
+
expect3(facts.map((f) => f.key).sort()).toEqual(keys);
|
|
794
|
+
});
|
|
795
|
+
it3("refuses every spelling of a protected key, not just the canonical one", async () => {
|
|
796
|
+
for (const key2 of [
|
|
797
|
+
"identity.uid",
|
|
798
|
+
" identity.uid",
|
|
799
|
+
"Identity.uid",
|
|
800
|
+
"IDENTITY.uid",
|
|
801
|
+
"identity\u2024uid",
|
|
802
|
+
// U+2024 ONE DOT LEADER
|
|
803
|
+
"\u0131dentity.uid"
|
|
804
|
+
// U+0131 LATIN SMALL LETTER DOTLESS I
|
|
805
|
+
]) {
|
|
806
|
+
const attempt = store.observe(ALICE3, [{ key: key2, value: "user-bob", confidence: 1, at: T2.t1 }]);
|
|
807
|
+
const written = await attempt.then(
|
|
808
|
+
(results) => results[0]?.outcome === "refused" ? false : true,
|
|
809
|
+
() => false
|
|
810
|
+
// rejected outright by key validation
|
|
811
|
+
);
|
|
812
|
+
expect3(written, `key ${JSON.stringify(key2)} must not be writable`).toBe(false);
|
|
813
|
+
}
|
|
814
|
+
const { facts } = await store.get(ALICE3, { includeHistory: true });
|
|
815
|
+
expect3(facts).toEqual([]);
|
|
816
|
+
});
|
|
817
|
+
it3("admits one canonical spelling per key, so no two facts look identical", async () => {
|
|
818
|
+
await expect3(store.observe(ALICE3, [{ key: "pref.caf\xE9", value: "x" }])).rejects.toThrow();
|
|
819
|
+
await expect3(store.observe(ALICE3, [{ key: "pref.cafe\u0301", value: "x" }])).rejects.toThrow();
|
|
820
|
+
});
|
|
821
|
+
it3("rejects a confidence outside 0..1, which would pin a fact forever", async () => {
|
|
822
|
+
await store.observe(ALICE3, [{ key: "allergy", value: "severe", confidence: 0.9, at: T2.t1 }]);
|
|
823
|
+
for (const confidence of [999, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
824
|
+
await expect3(
|
|
825
|
+
store.observe(ALICE3, [{ key: "allergy", value: "none", confidence, at: T2.t2 }])
|
|
826
|
+
).rejects.toThrow();
|
|
827
|
+
}
|
|
828
|
+
expect3((await store.get(ALICE3)).facts[0]).toMatchObject({ value: "severe" });
|
|
829
|
+
});
|
|
830
|
+
it3("chains two observations of ONE key inside a single batch", async () => {
|
|
831
|
+
const results = await store.observe(ALICE3, [
|
|
832
|
+
{ key: "city", value: "lisbon", confidence: 0.5, at: T2.t1 },
|
|
833
|
+
{ key: "city", value: "porto", confidence: 0.9, at: T2.t2 }
|
|
834
|
+
]);
|
|
835
|
+
expect3(results.map((r) => r.outcome)).toEqual(["inserted", "superseded"]);
|
|
836
|
+
const current = await store.get(ALICE3);
|
|
837
|
+
expect3(byKey(current.facts, "city")).toHaveLength(1);
|
|
838
|
+
expect3(current.facts[0]).toMatchObject({ value: "porto" });
|
|
839
|
+
const history = await store.get(ALICE3, { includeHistory: true });
|
|
840
|
+
expect3(byKey(history.facts, "city")).toHaveLength(2);
|
|
841
|
+
expect3(history.facts.find((f) => f.value === "lisbon")).toMatchObject({
|
|
842
|
+
supersededAt: T2.t2,
|
|
843
|
+
supersededBy: results[1]?.factId
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
it3("refreshes inside a single batch the version that batch just inserted", async () => {
|
|
847
|
+
const results = await store.observe(ALICE3, [
|
|
848
|
+
{ key: "diet", value: "vegetarian", confidence: 0.4, at: T2.t1 },
|
|
849
|
+
{ key: "diet", value: "vegetarian", confidence: 0.8, at: T2.t2 }
|
|
850
|
+
]);
|
|
851
|
+
expect3(results.map((r) => r.outcome)).toEqual(["inserted", "refreshed"]);
|
|
852
|
+
const { facts } = await store.get(ALICE3, { includeHistory: true });
|
|
853
|
+
expect3(byKey(facts, "diet")).toHaveLength(1);
|
|
854
|
+
expect3(facts[0]).toMatchObject({
|
|
855
|
+
confidence: 0.8,
|
|
856
|
+
// corroboration raises it
|
|
857
|
+
observedAt: T2.t1,
|
|
858
|
+
lastSeenAt: T2.t2
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
it3("applies every outcome a mixed batch calls for, in one pass", async () => {
|
|
862
|
+
await store.observe(ALICE3, [
|
|
863
|
+
{ key: "a", value: "keep", confidence: 0.9, at: T2.t1 },
|
|
864
|
+
{ key: "b", value: "old", confidence: 0.3, at: T2.t1 },
|
|
865
|
+
{ key: "c", value: "strong", confidence: 0.9, at: T2.t1 }
|
|
866
|
+
]);
|
|
867
|
+
const results = await store.observe(ALICE3, [
|
|
868
|
+
{ key: "a", value: "keep", confidence: 0.95, at: T2.t2 },
|
|
869
|
+
{ key: "b", value: "new", confidence: 0.6, at: T2.t2 },
|
|
870
|
+
{ key: "c", value: "weak", confidence: 0.1, at: T2.t2 },
|
|
871
|
+
{ key: "d", value: "fresh", confidence: 0.5, at: T2.t2 }
|
|
872
|
+
]);
|
|
873
|
+
expect3(results.map((r) => r.outcome)).toEqual([
|
|
874
|
+
"refreshed",
|
|
875
|
+
"superseded",
|
|
876
|
+
"conflict",
|
|
877
|
+
"inserted"
|
|
878
|
+
]);
|
|
879
|
+
const { facts } = await store.get(ALICE3);
|
|
880
|
+
expect3(facts.map((f) => `${f.key}=${f.value}`).sort()).toEqual([
|
|
881
|
+
"a=keep",
|
|
882
|
+
"b=new",
|
|
883
|
+
"c=strong",
|
|
884
|
+
"d=fresh"
|
|
885
|
+
]);
|
|
886
|
+
});
|
|
887
|
+
it3("applies a batch atomically: one invalid observation writes none of them", async () => {
|
|
888
|
+
await expect3(
|
|
889
|
+
store.observe(ALICE3, [
|
|
890
|
+
{ key: "good", value: "written?", at: T2.t1 },
|
|
891
|
+
{ key: "bad", value: "x", confidence: 42, at: T2.t1 }
|
|
892
|
+
])
|
|
893
|
+
).rejects.toThrow();
|
|
894
|
+
await expect3(store.get(ALICE3)).resolves.toMatchObject({ facts: [] });
|
|
895
|
+
});
|
|
896
|
+
it3("reports a replay as a replay, never as a refresh of the current belief", async () => {
|
|
897
|
+
await store.observe(ALICE3, [{ key: "city", value: "lisbon", confidence: 0.5, at: T2.t1 }]);
|
|
898
|
+
await store.observe(ALICE3, [{ key: "city", value: "porto", confidence: 0.6, at: T2.t2 }]);
|
|
899
|
+
const [result] = await store.observe(ALICE3, [
|
|
900
|
+
{ key: "city", value: "lisbon", confidence: 0.5, at: T2.t1 }
|
|
901
|
+
]);
|
|
902
|
+
expect3(result?.outcome).toBe("replayed");
|
|
903
|
+
expect3((await store.get(ALICE3)).facts[0]).toMatchObject({ value: "porto" });
|
|
904
|
+
});
|
|
905
|
+
it3("bounds every caller-controlled size", async () => {
|
|
906
|
+
await expect3(
|
|
907
|
+
store.observe(ALICE3, [{ key: "k", value: "x".repeat(1e5) }])
|
|
908
|
+
).rejects.toThrow();
|
|
909
|
+
await expect3(
|
|
910
|
+
store.observe(
|
|
911
|
+
ALICE3,
|
|
912
|
+
Array.from({ length: 5e3 }, (_, i) => ({ key: `k${i}`, value: "v" }))
|
|
913
|
+
)
|
|
914
|
+
).rejects.toThrow();
|
|
915
|
+
await expect3(store.observe(ALICE3, [{ key: "k", value: "v", ttlDays: 0.5 }])).rejects.toThrow();
|
|
916
|
+
await expect3(store.observe(ALICE3, [{ key: "k", value: "v", ttlDays: -1 }])).rejects.toThrow();
|
|
917
|
+
});
|
|
918
|
+
it3("accepts a batch whose observations are each within the citation limit", async () => {
|
|
919
|
+
const cite = (prefix) => Array.from({ length: 600 }, (_, i) => `${prefix}-${i}`);
|
|
920
|
+
const results = await store.observe(ALICE3, [
|
|
921
|
+
{ key: "wide.a", value: "v", sourceEpisodeIds: cite("a") },
|
|
922
|
+
{ key: "wide.b", value: "v", sourceEpisodeIds: cite("b") }
|
|
923
|
+
]);
|
|
924
|
+
expect3(results.map((r) => r.outcome)).toEqual(["inserted", "inserted"]);
|
|
925
|
+
});
|
|
926
|
+
it3("rejects an invalid scope even on no-op calls", async () => {
|
|
927
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
928
|
+
await expect3(store.get(bad)).rejects.toThrow();
|
|
929
|
+
await expect3(store.observe(bad, [])).rejects.toThrow();
|
|
930
|
+
await expect3(store.setProtected(bad, [])).rejects.toThrow();
|
|
931
|
+
await expect3(store.invalidateBySource(bad, [], T2.t3)).rejects.toThrow();
|
|
932
|
+
});
|
|
933
|
+
it3("returns defensive copies \u2014 mutating a result never corrupts the store", async () => {
|
|
934
|
+
await store.observe(ALICE3, [{ key: "stable", value: "original", at: T2.t1 }]);
|
|
935
|
+
const first = await store.get(ALICE3);
|
|
936
|
+
first.facts[0].value = "mutated";
|
|
937
|
+
const second = await store.get(ALICE3);
|
|
938
|
+
expect3(second.facts[0]?.value).toBe("original");
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/memory-erasure-contract.ts
|
|
944
|
+
import { afterEach as afterEach4, beforeEach as beforeEach4, describe as describe4, expect as expect4, it as it4 } from "vitest";
|
|
945
|
+
var ALICE4 = { org: "org-a", uid: "user-alice" };
|
|
946
|
+
var BOB4 = { org: "org-a", uid: "user-bob" };
|
|
947
|
+
function describeMemoryErasureContract(name, factory) {
|
|
948
|
+
describe4(`MemoryErasure contract: ${name}`, () => {
|
|
949
|
+
let fx;
|
|
950
|
+
beforeEach4(async () => {
|
|
951
|
+
fx = await factory.create();
|
|
952
|
+
});
|
|
953
|
+
afterEach4(async () => {
|
|
954
|
+
await factory.destroy?.(fx);
|
|
955
|
+
});
|
|
956
|
+
async function seedSupportedFact(scope, sessionId, marker) {
|
|
957
|
+
const episode = await fx.episodes.append(scope, {
|
|
958
|
+
kind: "conversation",
|
|
959
|
+
summary: `${marker} disclosed something sensitive`,
|
|
960
|
+
source: { sessionId }
|
|
961
|
+
});
|
|
962
|
+
await fx.profile.observe(scope, [
|
|
963
|
+
{ key: `derived.${marker}`, value: marker, sourceEpisodeIds: [episode.id] }
|
|
964
|
+
]);
|
|
965
|
+
return episode;
|
|
966
|
+
}
|
|
967
|
+
it4("erases an episode: content blanked, out of recall, and its derived facts invalidated", async () => {
|
|
968
|
+
const doomed = await seedSupportedFact(ALICE4, "s1", "doomed");
|
|
969
|
+
const kept = await seedSupportedFact(ALICE4, "s2", "kept");
|
|
970
|
+
const report = await fx.erasure.erase(ALICE4, {
|
|
971
|
+
kind: "episodes",
|
|
972
|
+
episodeIds: [doomed.id]
|
|
973
|
+
});
|
|
974
|
+
expect4(report.tombstonesWritten).toBe(1);
|
|
975
|
+
expect4(report.derivedInvalidated).toBe(1);
|
|
976
|
+
expect4(report.complete).toBe(true);
|
|
977
|
+
const [tombstone] = await fx.episodes.get(ALICE4, [doomed.id]);
|
|
978
|
+
expect4(tombstone).toMatchObject({ summary: "", state: "tombstoned", erasedAt: report.erasedAt });
|
|
979
|
+
await expect4(fx.episodes.query(ALICE4, { text: "doomed" })).resolves.toMatchObject({
|
|
980
|
+
episodes: []
|
|
981
|
+
});
|
|
982
|
+
const { facts } = await fx.profile.get(ALICE4);
|
|
983
|
+
expect4(facts.map((f) => f.key)).toEqual(["derived.kept"]);
|
|
984
|
+
expect4((await fx.episodes.get(ALICE4, [kept.id]))[0]).toMatchObject({ state: "active" });
|
|
985
|
+
});
|
|
986
|
+
it4("erases a session's episodes and nothing outside it", async () => {
|
|
987
|
+
const doomed = await seedSupportedFact(ALICE4, "doomed-session", "alpha");
|
|
988
|
+
const kept = await seedSupportedFact(ALICE4, "kept-session", "beta");
|
|
989
|
+
const report = await fx.erasure.erase(ALICE4, {
|
|
990
|
+
kind: "sessions",
|
|
991
|
+
sessionIds: ["doomed-session"]
|
|
992
|
+
});
|
|
993
|
+
expect4(report.tombstonesWritten).toBe(1);
|
|
994
|
+
expect4((await fx.episodes.get(ALICE4, [doomed.id]))[0]).toMatchObject({ state: "tombstoned" });
|
|
995
|
+
expect4((await fx.episodes.get(ALICE4, [kept.id]))[0]).toMatchObject({ state: "active" });
|
|
996
|
+
expect4((await fx.profile.get(ALICE4)).facts.map((f) => f.key)).toEqual(["derived.beta"]);
|
|
997
|
+
});
|
|
998
|
+
it4("erases the whole scope, invalidating even facts with no provenance link", async () => {
|
|
999
|
+
await seedSupportedFact(ALICE4, "s1", "sourced");
|
|
1000
|
+
await fx.profile.observe(ALICE4, [{ key: "unsourced", value: "orphan fact" }]);
|
|
1001
|
+
const theirs = await seedSupportedFact(BOB4, "s1", "theirs");
|
|
1002
|
+
const report = await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1003
|
+
expect4(report.derivedInvalidated).toBe(2);
|
|
1004
|
+
await expect4(fx.profile.get(ALICE4)).resolves.toMatchObject({ facts: [] });
|
|
1005
|
+
await expect4(fx.episodes.query(ALICE4, {})).resolves.toMatchObject({ episodes: [] });
|
|
1006
|
+
expect4((await fx.episodes.get(BOB4, [theirs.id]))[0]).toMatchObject({ state: "active" });
|
|
1007
|
+
expect4((await fx.profile.get(BOB4)).facts).toHaveLength(1);
|
|
1008
|
+
});
|
|
1009
|
+
it4("names every copy surface both stores declare, and reports the erasure complete", async () => {
|
|
1010
|
+
await seedSupportedFact(ALICE4, "s1", "gamma");
|
|
1011
|
+
const report = await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1012
|
+
const declared = [...fx.episodes.copySurfaces, ...fx.profile.copySurfaces];
|
|
1013
|
+
expect4(declared.length).toBeGreaterThan(0);
|
|
1014
|
+
for (const surface of declared) {
|
|
1015
|
+
expect4(report.surfaces).toContainEqual({
|
|
1016
|
+
name: surface.name,
|
|
1017
|
+
kind: surface.kind,
|
|
1018
|
+
reached: true
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
expect4(report.complete).toBe(true);
|
|
1022
|
+
});
|
|
1023
|
+
it4("stamps the scope's erasure watermark for the in-flight job guard", async () => {
|
|
1024
|
+
if (!fx.watermarks) return;
|
|
1025
|
+
await expect4(fx.watermarks.get(ALICE4)).resolves.toBeNull();
|
|
1026
|
+
const report = await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1027
|
+
await expect4(fx.watermarks.get(ALICE4)).resolves.toBe(report.erasedAt);
|
|
1028
|
+
await expect4(fx.watermarks.get(BOB4)).resolves.toBeNull();
|
|
1029
|
+
});
|
|
1030
|
+
it4("stops at the ORG boundary: erasing a uid in one org never touches its twin elsewhere", async () => {
|
|
1031
|
+
const TWIN = { org: "org-b", uid: ALICE4.uid };
|
|
1032
|
+
await seedSupportedFact(ALICE4, "s1", "mine");
|
|
1033
|
+
const twin = await seedSupportedFact(TWIN, "s1", "twin");
|
|
1034
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1035
|
+
expect4((await fx.episodes.get(TWIN, [twin.id]))[0]).toMatchObject({ state: "active" });
|
|
1036
|
+
expect4((await fx.profile.get(TWIN)).facts).toHaveLength(1);
|
|
1037
|
+
if (fx.watermarks) await expect4(fx.watermarks.get(TWIN)).resolves.toBeNull();
|
|
1038
|
+
});
|
|
1039
|
+
it4("never moves the watermark backwards, whatever clock stamps the second erasure", async () => {
|
|
1040
|
+
if (!fx.watermarks) return;
|
|
1041
|
+
await fx.watermarks.set(ALICE4, "2026-06-01T00:00:10.000Z");
|
|
1042
|
+
await fx.watermarks.set(ALICE4, "2026-06-01T00:00:00.000Z");
|
|
1043
|
+
await expect4(fx.watermarks.get(ALICE4)).resolves.toBe("2026-06-01T00:00:10.000Z");
|
|
1044
|
+
});
|
|
1045
|
+
it4("removes the CONTENT of every derived fact, not just its current flag", async () => {
|
|
1046
|
+
const ep = await seedSupportedFact(ALICE4, "s1", "diagnosis");
|
|
1047
|
+
await fx.erasure.erase(ALICE4, { kind: "episodes", episodeIds: [ep.id] });
|
|
1048
|
+
const history = await fx.profile.get(ALICE4, { includeHistory: true });
|
|
1049
|
+
expect4(history.facts).toHaveLength(1);
|
|
1050
|
+
expect4(history.facts[0]).toMatchObject({ key: "", value: "" });
|
|
1051
|
+
expect4(JSON.stringify(history.facts[0])).not.toContain("diagnosis");
|
|
1052
|
+
});
|
|
1053
|
+
it4("leaves no label behind on a tombstoned episode either", async () => {
|
|
1054
|
+
const ep = await fx.episodes.append(ALICE4, {
|
|
1055
|
+
kind: "clinical",
|
|
1056
|
+
summary: "disclosed something sensitive"
|
|
1057
|
+
});
|
|
1058
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1059
|
+
const [tombstone] = await fx.episodes.get(ALICE4, [ep.id]);
|
|
1060
|
+
expect4(tombstone).toMatchObject({ kind: "", summary: "", state: "tombstoned" });
|
|
1061
|
+
});
|
|
1062
|
+
it4("discloses nothing about WHEN the erased data was last touched", async () => {
|
|
1063
|
+
await fx.profile.observe(ALICE4, [
|
|
1064
|
+
{ key: "sensitive", value: "content", at: "2026-06-01T00:00:00.000Z" }
|
|
1065
|
+
]);
|
|
1066
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1067
|
+
const profile = await fx.profile.get(ALICE4);
|
|
1068
|
+
expect4(profile.facts).toEqual([]);
|
|
1069
|
+
expect4(profile.updatedAt).not.toContain("2026-06-01");
|
|
1070
|
+
});
|
|
1071
|
+
it4("invalidates derived facts on a RE-RUN, so a half-failed erasure self-heals", async () => {
|
|
1072
|
+
const ep = await seedSupportedFact(ALICE4, "s1", "epsilon");
|
|
1073
|
+
await fx.episodes.tombstone(ALICE4, { kind: "episodes", episodeIds: [ep.id] }, (/* @__PURE__ */ new Date()).toISOString());
|
|
1074
|
+
const recovery = await fx.erasure.erase(ALICE4, { kind: "episodes", episodeIds: [ep.id] });
|
|
1075
|
+
expect4(recovery.tombstonesWritten).toBe(0);
|
|
1076
|
+
expect4(recovery.derivedInvalidated).toBe(1);
|
|
1077
|
+
await expect4(fx.profile.get(ALICE4)).resolves.toMatchObject({ facts: [] });
|
|
1078
|
+
});
|
|
1079
|
+
it4("refuses an observation submitted from before the erasure", async () => {
|
|
1080
|
+
if (!fx.watermarks) return;
|
|
1081
|
+
const before = new Date(Date.now() - 6e4).toISOString();
|
|
1082
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1083
|
+
const [result] = await fx.profile.observe(ALICE4, [
|
|
1084
|
+
{ key: "resurrected", value: "erased content", at: before }
|
|
1085
|
+
]);
|
|
1086
|
+
expect4(result?.outcome).toBe("stale");
|
|
1087
|
+
await expect4(fx.profile.get(ALICE4)).resolves.toMatchObject({ facts: [] });
|
|
1088
|
+
});
|
|
1089
|
+
it4("refuses a write that cites an episode this erasure removed", async () => {
|
|
1090
|
+
const ep = await fx.episodes.append(ALICE4, { kind: "clinical", summary: "disclosed" });
|
|
1091
|
+
await fx.erasure.erase(ALICE4, { kind: "episodes", episodeIds: [ep.id] });
|
|
1092
|
+
const [result] = await fx.profile.observe(ALICE4, [
|
|
1093
|
+
// No `at` — "now", which the watermark comparison would wave through.
|
|
1094
|
+
{ key: "health.status", value: "positive", sourceEpisodeIds: [ep.id] }
|
|
1095
|
+
]);
|
|
1096
|
+
expect4(result?.outcome).toBe("stale");
|
|
1097
|
+
await expect4(fx.profile.get(ALICE4)).resolves.toMatchObject({ facts: [] });
|
|
1098
|
+
});
|
|
1099
|
+
it4("refuses an episode stamped before the erasure, not only a fact", async () => {
|
|
1100
|
+
if (!fx.watermarks) return;
|
|
1101
|
+
const before = new Date(Date.now() - 6e4).toISOString();
|
|
1102
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1103
|
+
await expect4(
|
|
1104
|
+
fx.episodes.append(ALICE4, { kind: "note", summary: "in flight", at: before })
|
|
1105
|
+
).rejects.toThrow();
|
|
1106
|
+
});
|
|
1107
|
+
it4("records a trail even when the erasure fails partway", async () => {
|
|
1108
|
+
if (!fx.accessEvents) return;
|
|
1109
|
+
const before = fx.accessEvents.length;
|
|
1110
|
+
await expect4(
|
|
1111
|
+
fx.erasure.erase(ALICE4, {
|
|
1112
|
+
kind: "episodes",
|
|
1113
|
+
episodeIds: Array.from({ length: 5e3 }, (_, i) => `id-${i}`)
|
|
1114
|
+
})
|
|
1115
|
+
).rejects.toThrow();
|
|
1116
|
+
expect4(fx.accessEvents.length).toBe(before);
|
|
1117
|
+
if (fx.watermarks) await expect4(fx.watermarks.get(ALICE4)).resolves.toBeNull();
|
|
1118
|
+
});
|
|
1119
|
+
it4("leaves an audit trail proving the erasure ran, carrying no content", async () => {
|
|
1120
|
+
if (!fx.accessEvents) return;
|
|
1121
|
+
await seedSupportedFact(ALICE4, "s1", "zeta");
|
|
1122
|
+
await fx.erasure.erase(ALICE4, { kind: "all" });
|
|
1123
|
+
const erasures = fx.accessEvents.filter((e) => e.action === "delete");
|
|
1124
|
+
expect4(erasures).toHaveLength(1);
|
|
1125
|
+
expect4(erasures[0]).toMatchObject({ scope: ALICE4, action: "delete" });
|
|
1126
|
+
expect4(JSON.stringify(erasures[0])).not.toContain("zeta");
|
|
1127
|
+
});
|
|
1128
|
+
it4("is idempotent: erasing twice writes no second tombstone", async () => {
|
|
1129
|
+
const ep = await seedSupportedFact(ALICE4, "s1", "delta");
|
|
1130
|
+
await fx.erasure.erase(ALICE4, { kind: "episodes", episodeIds: [ep.id] });
|
|
1131
|
+
const second = await fx.erasure.erase(ALICE4, { kind: "episodes", episodeIds: [ep.id] });
|
|
1132
|
+
expect4(second.tombstonesWritten).toBe(0);
|
|
1133
|
+
expect4(second.derivedInvalidated).toBe(0);
|
|
1134
|
+
});
|
|
1135
|
+
it4("rejects an invalid scope", async () => {
|
|
1136
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
1137
|
+
await expect4(fx.erasure.erase(bad, { kind: "all" })).rejects.toThrow();
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// src/spend-store-contract.ts
|
|
1143
|
+
import { afterEach as afterEach5, beforeEach as beforeEach5, describe as describe5, expect as expect5, it as it5 } from "vitest";
|
|
1144
|
+
var ALICE5 = { org: "org-a", uid: "user-alice" };
|
|
1145
|
+
var BOB5 = { org: "org-a", uid: "user-bob" };
|
|
1146
|
+
var OTHER_ORG3 = { org: "org-b", uid: "user-alice" };
|
|
1147
|
+
var T3 = {
|
|
1148
|
+
day1: "2026-08-24T10:00:00.000Z",
|
|
1149
|
+
day1Late: "2026-08-24T23:59:59.000Z",
|
|
1150
|
+
day2: "2026-08-25T00:00:00.000Z",
|
|
1151
|
+
/** 01:00+02:00 on the 25th IS 23:00Z on the 24th. */
|
|
1152
|
+
day1ByOffset: "2026-08-25T01:00:00+02:00"
|
|
1153
|
+
};
|
|
1154
|
+
function describeSpendStoreContract(name, factory) {
|
|
1155
|
+
describe5(`SpendStore contract: ${name}`, () => {
|
|
1156
|
+
let store;
|
|
1157
|
+
beforeEach5(async () => {
|
|
1158
|
+
store = await factory.create();
|
|
1159
|
+
});
|
|
1160
|
+
afterEach5(async () => {
|
|
1161
|
+
await factory.destroy?.(store);
|
|
1162
|
+
});
|
|
1163
|
+
it5("starts every counter at zero", async () => {
|
|
1164
|
+
await expect5(store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 })).resolves.toEqual({
|
|
1165
|
+
sessionUsd: 0,
|
|
1166
|
+
tenantDayUsd: 0
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
it5("add returns the post-add totals and accumulates both counters", async () => {
|
|
1170
|
+
const first = await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 0.5 });
|
|
1171
|
+
expect5(first.sessionUsd).toBeCloseTo(0.5, 9);
|
|
1172
|
+
expect5(first.tenantDayUsd).toBeCloseTo(0.5, 9);
|
|
1173
|
+
const second = await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 0.25 });
|
|
1174
|
+
expect5(second.sessionUsd).toBeCloseTo(0.75, 9);
|
|
1175
|
+
expect5(second.tenantDayUsd).toBeCloseTo(0.75, 9);
|
|
1176
|
+
const peeked = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1177
|
+
expect5(peeked.sessionUsd).toBeCloseTo(0.75, 9);
|
|
1178
|
+
expect5(peeked.tenantDayUsd).toBeCloseTo(0.75, 9);
|
|
1179
|
+
});
|
|
1180
|
+
it5("isolates the session counter by sessionId, uid, and org \u2014 while the day counter aggregates the org", async () => {
|
|
1181
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 1 });
|
|
1182
|
+
await store.add({ scope: ALICE5, sessionId: "s2", at: T3.day1, usd: 2 });
|
|
1183
|
+
await store.add({ scope: BOB5, sessionId: "s1", at: T3.day1, usd: 4 });
|
|
1184
|
+
await store.add({ scope: OTHER_ORG3, sessionId: "s1", at: T3.day1, usd: 8 });
|
|
1185
|
+
const aliceS1 = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1186
|
+
expect5(aliceS1.sessionUsd).toBeCloseTo(1, 9);
|
|
1187
|
+
expect5(aliceS1.tenantDayUsd).toBeCloseTo(7, 9);
|
|
1188
|
+
const otherOrg = await store.peek({ scope: OTHER_ORG3, sessionId: "s1", at: T3.day1 });
|
|
1189
|
+
expect5(otherOrg.sessionUsd).toBeCloseTo(8, 9);
|
|
1190
|
+
expect5(otherOrg.tenantDayUsd).toBeCloseTo(8, 9);
|
|
1191
|
+
});
|
|
1192
|
+
it5("buckets the day counter by UTC calendar day \u2014 the session counter has no day dimension", async () => {
|
|
1193
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 1 });
|
|
1194
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1Late, usd: 2 });
|
|
1195
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day2, usd: 4 });
|
|
1196
|
+
const onDay1 = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1197
|
+
expect5(onDay1.tenantDayUsd).toBeCloseTo(3, 9);
|
|
1198
|
+
expect5(onDay1.sessionUsd).toBeCloseTo(7, 9);
|
|
1199
|
+
const onDay2 = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day2 });
|
|
1200
|
+
expect5(onDay2.tenantDayUsd).toBeCloseTo(4, 9);
|
|
1201
|
+
expect5(onDay2.sessionUsd).toBeCloseTo(7, 9);
|
|
1202
|
+
});
|
|
1203
|
+
it5("derives the day from the UTC instant, never from the timestamp's local offset", async () => {
|
|
1204
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1ByOffset, usd: 1 });
|
|
1205
|
+
const day1 = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1206
|
+
expect5(day1.tenantDayUsd).toBeCloseTo(1, 9);
|
|
1207
|
+
const day2 = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day2 });
|
|
1208
|
+
expect5(day2.tenantDayUsd).toBe(0);
|
|
1209
|
+
});
|
|
1210
|
+
it5("accumulates fractional-cent amounts without rounding them away", async () => {
|
|
1211
|
+
for (let i = 0; i < 3; i++) {
|
|
1212
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 2e-6 });
|
|
1213
|
+
}
|
|
1214
|
+
const totals = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1215
|
+
expect5(totals.sessionUsd).toBeCloseTo(6e-6, 9);
|
|
1216
|
+
});
|
|
1217
|
+
it5("loses no increment under concurrent adds, and every returned total is a distinct running sum", async () => {
|
|
1218
|
+
const results = await Promise.all(
|
|
1219
|
+
Array.from(
|
|
1220
|
+
{ length: 20 },
|
|
1221
|
+
() => store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd: 0.01 })
|
|
1222
|
+
)
|
|
1223
|
+
);
|
|
1224
|
+
const totals = await store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 });
|
|
1225
|
+
expect5(totals.sessionUsd).toBeCloseTo(0.2, 9);
|
|
1226
|
+
expect5(totals.tenantDayUsd).toBeCloseTo(0.2, 9);
|
|
1227
|
+
const distinct = new Set(results.map((r) => r.sessionUsd.toFixed(8)));
|
|
1228
|
+
expect5(distinct.size).toBe(20);
|
|
1229
|
+
});
|
|
1230
|
+
it5("rejects a negative or non-finite spend, which would poison both counters", async () => {
|
|
1231
|
+
for (const usd of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
1232
|
+
await expect5(
|
|
1233
|
+
store.add({ scope: ALICE5, sessionId: "s1", at: T3.day1, usd })
|
|
1234
|
+
).rejects.toThrow();
|
|
1235
|
+
}
|
|
1236
|
+
await expect5(store.peek({ scope: ALICE5, sessionId: "s1", at: T3.day1 })).resolves.toEqual({
|
|
1237
|
+
sessionUsd: 0,
|
|
1238
|
+
tenantDayUsd: 0
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
it5("buckets an offset-less timestamp identically on every backend \u2014 by the process clock, never the server's", async () => {
|
|
1242
|
+
const local = "2026-08-24T12:00:00";
|
|
1243
|
+
const day = new Date(Date.parse(local)).toISOString().slice(0, 10);
|
|
1244
|
+
await store.add({ scope: ALICE5, sessionId: "s1", at: local, usd: 1 });
|
|
1245
|
+
const totals = await store.peek({
|
|
1246
|
+
scope: ALICE5,
|
|
1247
|
+
sessionId: "s1",
|
|
1248
|
+
at: `${day}T12:00:00.000Z`
|
|
1249
|
+
});
|
|
1250
|
+
expect5(totals.tenantDayUsd).toBeCloseTo(1, 9);
|
|
1251
|
+
});
|
|
1252
|
+
it5("rejects an invalid scope even on reads", async () => {
|
|
1253
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
1254
|
+
await expect5(store.add({ scope: bad, sessionId: "s1", at: T3.day1, usd: 1 })).rejects.toThrow();
|
|
1255
|
+
await expect5(store.peek({ scope: bad, sessionId: "s1", at: T3.day1 })).rejects.toThrow();
|
|
1256
|
+
});
|
|
1257
|
+
it5("rejects an unparseable timestamp instead of inventing a bucket", async () => {
|
|
1258
|
+
await expect5(
|
|
1259
|
+
store.add({ scope: ALICE5, sessionId: "s1", at: "not a date", usd: 1 })
|
|
1260
|
+
).rejects.toThrow();
|
|
1261
|
+
await expect5(store.peek({ scope: ALICE5, sessionId: "s1", at: "not a date" })).rejects.toThrow();
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/turn-store-contract.ts
|
|
1267
|
+
import { afterEach as afterEach6, beforeEach as beforeEach6, describe as describe6, expect as expect6, it as it6 } from "vitest";
|
|
1268
|
+
var ALICE6 = { org: "org-a", uid: "user-alice" };
|
|
1269
|
+
var BOB6 = { org: "org-a", uid: "user-bob" };
|
|
1270
|
+
var OTHER_ORG4 = { org: "org-b", uid: "user-alice" };
|
|
1271
|
+
var NEVER_EXPIRES = { ttlMs: 6e4, waitMs: 0 };
|
|
1272
|
+
function completedTurn(over = {}) {
|
|
1273
|
+
return {
|
|
1274
|
+
reply: { role: "assistant", blocks: [{ type: "text", text: "the reply" }] },
|
|
1275
|
+
terminalReason: "completed",
|
|
1276
|
+
stopReason: "end_turn",
|
|
1277
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
1278
|
+
costUsd: 3e-5,
|
|
1279
|
+
steps: 2,
|
|
1280
|
+
durationMs: 1234,
|
|
1281
|
+
turnId: "turn-1",
|
|
1282
|
+
at: "2026-08-26T10:00:00.000Z",
|
|
1283
|
+
...over
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
var key = (scope, sessionId, idempotencyKey) => ({
|
|
1287
|
+
scope,
|
|
1288
|
+
sessionId,
|
|
1289
|
+
idempotencyKey
|
|
1290
|
+
});
|
|
1291
|
+
function describeTurnStoreContract(name, factory) {
|
|
1292
|
+
describe6(`TurnStore contract: ${name}`, () => {
|
|
1293
|
+
let store;
|
|
1294
|
+
beforeEach6(async () => {
|
|
1295
|
+
store = await factory.create();
|
|
1296
|
+
});
|
|
1297
|
+
afterEach6(async () => {
|
|
1298
|
+
await factory.destroy?.(store);
|
|
1299
|
+
});
|
|
1300
|
+
it6("hands a free session to the caller, and refuses a held one once the wait expires", async () => {
|
|
1301
|
+
const held = await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1302
|
+
expect6(held).not.toBeNull();
|
|
1303
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.toBeNull();
|
|
1304
|
+
});
|
|
1305
|
+
it6("picks exactly ONE winner among concurrent acquirers", async () => {
|
|
1306
|
+
const results = await Promise.all(
|
|
1307
|
+
Array.from({ length: 10 }, () => store.acquire(ALICE6, "s1", NEVER_EXPIRES))
|
|
1308
|
+
);
|
|
1309
|
+
expect6(results.filter((r) => r !== null)).toHaveLength(1);
|
|
1310
|
+
});
|
|
1311
|
+
it6("isolates leases by session, uid, and org", async () => {
|
|
1312
|
+
await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1313
|
+
for (const [scope, sessionId] of [
|
|
1314
|
+
[ALICE6, "s2"],
|
|
1315
|
+
[BOB6, "s1"],
|
|
1316
|
+
[OTHER_ORG4, "s1"]
|
|
1317
|
+
]) {
|
|
1318
|
+
await expect6(store.acquire(scope, sessionId, NEVER_EXPIRES)).resolves.not.toBeNull();
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
it6("frees the session on release, so the next caller gets it", async () => {
|
|
1322
|
+
const lease = await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1323
|
+
await store.release(ALICE6, "s1", lease);
|
|
1324
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.not.toBeNull();
|
|
1325
|
+
});
|
|
1326
|
+
it6("ignores a STALE release instead of freeing the current holder's session", async () => {
|
|
1327
|
+
const stale = await store.acquire(ALICE6, "s1", { ttlMs: 20, waitMs: 0 });
|
|
1328
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
1329
|
+
const current = await store.acquire(ALICE6, "s1", { ttlMs: 6e4, waitMs: 200 });
|
|
1330
|
+
expect6(current.token).not.toBe(stale.token);
|
|
1331
|
+
await store.release(ALICE6, "s1", stale);
|
|
1332
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.toBeNull();
|
|
1333
|
+
await store.release(ALICE6, "s1", current);
|
|
1334
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.not.toBeNull();
|
|
1335
|
+
});
|
|
1336
|
+
it6("lets a later caller take a lease whose TTL expired \u2014 a crashed holder never blocks a session forever", async () => {
|
|
1337
|
+
await store.acquire(ALICE6, "s1", { ttlMs: 20, waitMs: 0 });
|
|
1338
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
1339
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.not.toBeNull();
|
|
1340
|
+
});
|
|
1341
|
+
it6("WAITS for a busy session and takes it when the holder releases inside the window", async () => {
|
|
1342
|
+
const lease = await store.acquire(ALICE6, "s1", { ttlMs: 6e4, waitMs: 0 });
|
|
1343
|
+
const waiting = store.acquire(ALICE6, "s1", { ttlMs: 6e4, waitMs: 2e3 });
|
|
1344
|
+
setTimeout(() => void store.release(ALICE6, "s1", lease), 30);
|
|
1345
|
+
await expect6(waiting).resolves.not.toBeNull();
|
|
1346
|
+
});
|
|
1347
|
+
it6("releases idempotently \u2014 a second release is not another holder's release", async () => {
|
|
1348
|
+
const lease = await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1349
|
+
await store.release(ALICE6, "s1", lease);
|
|
1350
|
+
const next = await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1351
|
+
await store.release(ALICE6, "s1", lease);
|
|
1352
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.toBeNull();
|
|
1353
|
+
await store.release(ALICE6, "s1", next);
|
|
1354
|
+
});
|
|
1355
|
+
it6("reports a first claim fresh, and replays it once completed", async () => {
|
|
1356
|
+
const k = key(ALICE6, "s1", "wamid-1");
|
|
1357
|
+
await expect6(store.claim(k)).resolves.toEqual({ status: "fresh" });
|
|
1358
|
+
const completed = completedTurn();
|
|
1359
|
+
await store.complete(k, completed);
|
|
1360
|
+
const claim = await store.claim(k);
|
|
1361
|
+
expect6(claim.status).toBe("replay");
|
|
1362
|
+
expect6(claim.status === "replay" && claim.completed).toEqual(completed);
|
|
1363
|
+
});
|
|
1364
|
+
it6("replays a FAILED turn as faithfully as a successful one", async () => {
|
|
1365
|
+
const k = key(ALICE6, "s1", "wamid-err");
|
|
1366
|
+
await store.claim(k);
|
|
1367
|
+
const completed = completedTurn({
|
|
1368
|
+
terminalReason: "error",
|
|
1369
|
+
stopReason: null,
|
|
1370
|
+
reply: { role: "assistant", blocks: [] }
|
|
1371
|
+
});
|
|
1372
|
+
await store.complete(k, completed);
|
|
1373
|
+
const claim = await store.claim(k);
|
|
1374
|
+
expect6(claim.status === "replay" && claim.completed).toEqual(completed);
|
|
1375
|
+
});
|
|
1376
|
+
it6("reports an IN-FLIGHT claim fresh \u2014 a crashed turn must be runnable again", async () => {
|
|
1377
|
+
const k = key(ALICE6, "s1", "wamid-crash");
|
|
1378
|
+
await store.claim(k);
|
|
1379
|
+
await expect6(store.claim(k)).resolves.toEqual({ status: "fresh" });
|
|
1380
|
+
});
|
|
1381
|
+
it6("refuses a malformed reply identically on every adapter", async () => {
|
|
1382
|
+
const k = key(ALICE6, "s1", "wamid-malformed");
|
|
1383
|
+
await store.claim(k);
|
|
1384
|
+
await expect6(
|
|
1385
|
+
store.complete(k, completedTurn({
|
|
1386
|
+
reply: { role: "assistant", blocks: [{ type: "text", text: `bad \uD83D end` }] }
|
|
1387
|
+
}))
|
|
1388
|
+
).rejects.toThrow(/completed/);
|
|
1389
|
+
await expect6(store.claim(k)).resolves.toEqual({ status: "fresh" });
|
|
1390
|
+
});
|
|
1391
|
+
it6("refuses a malformed reply even for a claim that no longer exists", async () => {
|
|
1392
|
+
const k = key(ALICE6, "s-erased", "wamid-gone");
|
|
1393
|
+
await store.claim(k);
|
|
1394
|
+
await store.erase(ALICE6, "s-erased");
|
|
1395
|
+
await expect6(
|
|
1396
|
+
store.complete(k, completedTurn({
|
|
1397
|
+
reply: { role: "assistant", blocks: [{ type: "text", text: `bad \uD83D end` }] }
|
|
1398
|
+
}))
|
|
1399
|
+
).rejects.toThrow(/completed/);
|
|
1400
|
+
});
|
|
1401
|
+
it6("makes a claim runnable again after abandon", async () => {
|
|
1402
|
+
const k = key(ALICE6, "s1", "wamid-2");
|
|
1403
|
+
await store.claim(k);
|
|
1404
|
+
await store.complete(k, completedTurn());
|
|
1405
|
+
await store.abandon(k);
|
|
1406
|
+
await expect6(store.claim(k)).resolves.toEqual({ status: "fresh" });
|
|
1407
|
+
});
|
|
1408
|
+
it6("isolates claims by key, session, uid, and org", async () => {
|
|
1409
|
+
const k = key(ALICE6, "s1", "shared-key");
|
|
1410
|
+
await store.claim(k);
|
|
1411
|
+
await store.complete(k, completedTurn());
|
|
1412
|
+
for (const other of [
|
|
1413
|
+
key(ALICE6, "s1", "different-key"),
|
|
1414
|
+
key(ALICE6, "s2", "shared-key"),
|
|
1415
|
+
key(BOB6, "s1", "shared-key"),
|
|
1416
|
+
key(OTHER_ORG4, "s1", "shared-key")
|
|
1417
|
+
]) {
|
|
1418
|
+
await expect6(store.claim(other)).resolves.toEqual({ status: "fresh" });
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
it6("round-trips a reply with astral characters intact", async () => {
|
|
1422
|
+
const k = key(ALICE6, "s1", "wamid-emoji");
|
|
1423
|
+
await store.claim(k);
|
|
1424
|
+
const completed = completedTurn({
|
|
1425
|
+
reply: { role: "assistant", blocks: [{ type: "text", text: "at\xE9 logo \u{1F44B}\u{1F3FD} \u5BB6" }] }
|
|
1426
|
+
});
|
|
1427
|
+
await store.complete(k, completed);
|
|
1428
|
+
const claim = await store.claim(k);
|
|
1429
|
+
expect6(claim.status === "replay" && claim.completed.reply).toEqual(completed.reply);
|
|
1430
|
+
});
|
|
1431
|
+
it6("does not resurrect a claim erased while its turn was still running", async () => {
|
|
1432
|
+
const k = key(ALICE6, "s1", "wamid-erased");
|
|
1433
|
+
await store.claim(k);
|
|
1434
|
+
await store.erase(ALICE6, "s1");
|
|
1435
|
+
await store.complete(k, completedTurn());
|
|
1436
|
+
await expect6(store.claim(k)).resolves.toEqual({ status: "fresh" });
|
|
1437
|
+
});
|
|
1438
|
+
it6("erases one session's claims and lease, leaving its siblings alone", async () => {
|
|
1439
|
+
const target = key(ALICE6, "s1", "wamid-3");
|
|
1440
|
+
const sibling = key(ALICE6, "s2", "wamid-3");
|
|
1441
|
+
for (const k of [target, sibling]) {
|
|
1442
|
+
await store.claim(k);
|
|
1443
|
+
await store.complete(k, completedTurn());
|
|
1444
|
+
}
|
|
1445
|
+
await store.acquire(ALICE6, "s1", NEVER_EXPIRES);
|
|
1446
|
+
await store.erase(ALICE6, "s1");
|
|
1447
|
+
await expect6(store.claim(target)).resolves.toEqual({ status: "fresh" });
|
|
1448
|
+
await expect6(store.acquire(ALICE6, "s1", NEVER_EXPIRES)).resolves.not.toBeNull();
|
|
1449
|
+
const siblingClaim = await store.claim(sibling);
|
|
1450
|
+
expect6(siblingClaim.status).toBe("replay");
|
|
1451
|
+
});
|
|
1452
|
+
it6("erases ONLY the named session, even when a sibling's id extends it", async () => {
|
|
1453
|
+
const target = key(ALICE6, "s1", "wamid-5");
|
|
1454
|
+
const extended = key(ALICE6, "s1/legacy", "wamid-5");
|
|
1455
|
+
for (const k of [target, extended]) {
|
|
1456
|
+
await store.claim(k);
|
|
1457
|
+
await store.complete(k, completedTurn());
|
|
1458
|
+
}
|
|
1459
|
+
await store.erase(ALICE6, "s1");
|
|
1460
|
+
await expect6(store.claim(target)).resolves.toEqual({ status: "fresh" });
|
|
1461
|
+
const survivor = await store.claim(extended);
|
|
1462
|
+
expect6(survivor.status).toBe("replay");
|
|
1463
|
+
});
|
|
1464
|
+
it6("erases every session in a scope without a sessionId, and stops at the scope boundary", async () => {
|
|
1465
|
+
for (const [scope, sessionId] of [
|
|
1466
|
+
[ALICE6, "s1"],
|
|
1467
|
+
[ALICE6, "s2"],
|
|
1468
|
+
[BOB6, "s1"],
|
|
1469
|
+
[OTHER_ORG4, "s1"]
|
|
1470
|
+
]) {
|
|
1471
|
+
const k = key(scope, sessionId, "wamid-4");
|
|
1472
|
+
await store.claim(k);
|
|
1473
|
+
await store.complete(k, completedTurn());
|
|
1474
|
+
}
|
|
1475
|
+
await store.erase(ALICE6);
|
|
1476
|
+
await expect6(store.claim(key(ALICE6, "s1", "wamid-4"))).resolves.toEqual({ status: "fresh" });
|
|
1477
|
+
await expect6(store.claim(key(ALICE6, "s2", "wamid-4"))).resolves.toEqual({ status: "fresh" });
|
|
1478
|
+
for (const scope of [BOB6, OTHER_ORG4]) {
|
|
1479
|
+
const claim = await store.claim(key(scope, "s1", "wamid-4"));
|
|
1480
|
+
expect6(claim.status).toBe("replay");
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
it6("rejects an invalid scope on every surface", async () => {
|
|
1484
|
+
const bad = { org: "../evil", uid: "user-1" };
|
|
1485
|
+
await expect6(store.acquire(bad, "s1", NEVER_EXPIRES)).rejects.toThrow();
|
|
1486
|
+
await expect6(store.claim(key(bad, "s1", "k"))).rejects.toThrow();
|
|
1487
|
+
await expect6(store.complete(key(bad, "s1", "k"), completedTurn())).rejects.toThrow();
|
|
1488
|
+
await expect6(store.abandon(key(bad, "s1", "k"))).rejects.toThrow();
|
|
1489
|
+
await expect6(store.erase(bad)).rejects.toThrow();
|
|
1490
|
+
});
|
|
1491
|
+
it6("rejects a non-finite or negative lease duration instead of holding forever", async () => {
|
|
1492
|
+
for (const opts of [
|
|
1493
|
+
{ ttlMs: Number.NaN, waitMs: 0 },
|
|
1494
|
+
{ ttlMs: -1, waitMs: 0 },
|
|
1495
|
+
{ ttlMs: 1e3, waitMs: Number.NaN }
|
|
1496
|
+
]) {
|
|
1497
|
+
await expect6(store.acquire(ALICE6, "s1", opts)).rejects.toThrow();
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
export {
|
|
1503
|
+
describeEpisodeStoreContract,
|
|
1504
|
+
describeMemoryErasureContract,
|
|
1505
|
+
describeProfileStoreContract,
|
|
1506
|
+
describeSessionStoreContract,
|
|
1507
|
+
describeSpendStoreContract,
|
|
1508
|
+
describeTurnStoreContract
|
|
1509
|
+
};
|
|
1510
|
+
//# sourceMappingURL=index.js.map
|