@agent-custody/state 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -45,6 +45,7 @@ In the gateway's config, the memory server is the upstream, and the grant names
45
45
  | `memory.confirm` | lifts a quarantined fact to attested; accepted only through the gateway | `factId` |
46
46
  | `memory.retract` | undoes a belief, keeping it visible to questions about the past | `factId`, `reason` |
47
47
  | `memory.forget` | erases the value from the ledger and every store, keeping the digest; the receipt is the certificate | `factId`, `reason` |
48
+ | `pack` (CLI) | one fact's history, receipts, holds, blast radius, and forget certificate as one signed artefact, verifiable offline | |
48
49
  | `memory.hold`, `memory.release` | legal hold: while it stands the fact cannot be forgotten by request or sweep | `factId`, `reason` |
49
50
  | `memory.sweep` | retention: forget what was learned before an instant, in a space or all, skipping held facts, reaching every store | `before`, `space`, `reason` |
50
51
  | `memory.get` | one fact by id in any state, for the gateway's policy lookups | `factId` |
@@ -84,6 +85,12 @@ A deletion demand is different from a correction. Retract keeps the record; forg
84
85
 
85
86
  **Retention and legal hold.** `memory.sweep` forgets every fact the ledger learned of before an instant, in one space or all, and removes each from every store; it is retention as a receipted call, with the receipt as the record of what was erased. Retention windows live in the server: `serve --retention 'org=P365D,team:*=P90D,user:*=P30D'`, ISO 8601 durations by space pattern, and a sweep with no `before` uses them per space. To run it on a schedule without a daemon, `agent-custody-memory sweep --via retention-gateway.json --reason "quarterly retention"` spawns that gateway and calls `memory.sweep` through it, so the sweep runs as the principal named in the gateway's grant, a retention job with the single scope `memory.sweep`, and its receipt records when retention ran, by whom, what it erased, and what a hold kept. Any cron, systemd timer, or CI schedule drives that one command. `memory.hold` puts a legal hold on a fact: while it stands, neither a deletion request nor a sweep can forget it, and `memory.release` lifts it. Holds and releases are events with actor, reason, and receipt, so the history of a fact shows the hold as plainly as the write. `agent-custody-memory sweep --ledger ... --before ... --reason ...` runs retention on the ledger file alone, for ledgers with no stores behind them.
86
87
 
88
+ What forget reaches is the ledger and the stores with adapters. It does not reach caches, a model's context window, application logs, or any store without an adapter. Say that plainly to whoever is relying on it.
89
+
90
+ **Removal is verified, not assumed.** A delete by id and a search index catching up are different moments. After every retraction, forget, or sweep, the server asks each store's own search whether the fact still surfaces, a few times with backoff, and records the outcome per store in the result and so in the receipt: `verified` (the store's search no longer finds it), `stillIndexed` (it still does, after the retries), `unverified` (the store cannot be asked, or the adapter has no search), or `failed` (the removal itself failed). Mem0 and Zep both verify through their search APIs; the tests drive the real clients against indexes that lag by a configurable number of searches. A certificate that says `stillIndexed` is an honest certificate; run forget again later, or read it as the store's problem to fix.
91
+
92
+ **The pack.** `agent-custody-memory pack --ledger ... --receipts ... --fact <id> --out pack.json --sign keys/pack.key` gathers everything about one fact into one signed artefact: its history with the receipt that produced each event, its holds and releases, its blast radius with every downstream receipt, and, if it was forgotten, the forget certificate with what each store answered. `pack --verify pack.json --key keys/pack.pub --issuer-key keys/gateway.pub --principal-key keys/principal.pub` checks the pack's signature and digest, every receipt inside it against the gateway's keys, that every event's receipt is present, and that the forget receipt names this fact and records the erasure. Touch one receipt inside and the whole pack fails. It is the artefact counsel attaches to a ticket; the reviewer needs the two public keys and nothing from you.
93
+
87
94
  The certificate is the receipt. Through the gateway, `memory.forget` is a receipted call whose result the gateway observed, so the signed, logged receipt records that the erasure happened, who asked for it, and what the stores answered. Hand that receipt to whoever demanded the deletion; anyone with the gateway's public key can verify it.
88
95
 
89
96
  Receipts are the other place a value lives: the receipt that recorded the write carries it in its request arguments, and the receipts of reads carry it in their results, signed and in a Merkle log. The receipts package's `prune` command is retention for the log: it replaces older leaves with their hashes, so every root and every proof still verifies while the content is gone, and removes the pruned receipts' bundle files. Run it on the same schedule as the sweep.
@@ -138,9 +145,23 @@ import { Ledger, ledgerUnderTest, runAll, SCENARIOS, formatReport } from "@agent
138
145
  console.log(formatReport(await runAll(ledgerUnderTest(new Ledger("./ledger.jsonl")), SCENARIOS)));
139
146
  ```
140
147
 
148
+ From the shell, on a schedule:
149
+
150
+ ```bash
151
+ agent-custody-memory eval --baseline # built-in scenarios, ledger and a naive store side by side
152
+ agent-custody-memory eval --scenarios incidents.json --sign keys/eval.key --out report.json # your own incidents, signed report
153
+ agent-custody-memory eval --verify report.json --key keys/eval.pub # what a reviewer runs
154
+ ```
155
+
156
+ A scenario file is `{ "version": "0.1", "scenarios": [{ "name", "ops": [...] }] }` with the same write, read, and retract ops as the built-ins; every read carries `expect`, null meaning nothing. The file is validated and a bad one names the offending op. The signed report is an in-toto statement over the scores, bound to a digest of the scenarios it ran, in the same envelope format as receipts; `eval --verify` checks the signature and the digest. `eval` exits non-zero when the ledger scores below perfect on the scenarios it was given, so a cron job that runs it fails loudly when a change regresses memory behaviour.
157
+
141
158
  ## The ledger
142
159
 
143
- `src/ledger.ts` is an append-only JSONL log of two kinds of event.
160
+ `src/ledger.ts` keeps an append-only log of events, in memory for its queries and in one of two stores for durability. **JSONL** is the default: one event per line, readable by anyone, the file you copy for an audit. **SQLite** is for durability and shared use, chosen by giving the ledger a path ending in `.sqlite` or `.db`: transactional writes with write-ahead logging, an in-place forget that overwrites the erased value (secure delete, then a truncating checkpoint so nothing lingers in the write-ahead log), indexes by fact and by time, and a file more than one process can open. Node ships the SQLite module, so there is no native dependency; on Node 22 it prints an experimental warning once. `agent-custody-memory export --ledger ledger.sqlite --out ledger.jsonl` writes the auditable JSONL from any store, so the copy-the-file audit path survives the choice. The whole ledger test suite runs against both stores.
161
+
162
+ Choose JSONL for one server process and for anything an auditor should be able to read with `cat`. Choose SQLite when the ledger is shared over HTTP by several gateways, when a crash between two writes must not cost you an event, or when a deletion demand must leave no trace of the value in the file. Query pushdown to SQLite's indexes is not done yet: both stores load every event into memory, which is fine to tens of thousands of facts. [Issue #8](https://github.com/ch4r10t33r/agent-custody/issues/8) tracks indexed queries for larger ledgers.
163
+
164
+ The log holds these kinds of event.
144
165
 
145
166
  - **assert** creates a fact: subject, predicate, value, the space it lives in (a person, a team, an org), the actor that wrote it, the source receipt id if the write went through a receipts producer, and the valid-time interval. An assert may **supersede** an earlier fact, which ends that fact's validity where the new one begins.
146
167
  - **retract** says a fact should never have been believed. This is the undo. The record stays, so queries about earlier moments still see the fact, and anything the retracted fact had superseded is believed again.
@@ -159,14 +180,17 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
159
180
  ## Layout
160
181
 
161
182
  ```
162
- src/ledger.ts the fact record, the two event kinds, as-of queries, supersession, retraction, JSONL persistence
183
+ src/ledger.ts the fact record, the event kinds, as-of queries, supersession, retraction, forget, holds, sweeps
184
+ src/storage.ts the event stores: JSONL (default, auditable) and SQLite (durable, shared), chosen by file extension
163
185
  src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
164
186
  src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
165
- src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), blast
187
+ src/pack.ts the custody pack: build, sign, verify, format
188
+ src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, pack, export, blast
166
189
  src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
167
190
  src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
168
191
  src/evals.ts the memory-mutation harness: scenarios, scoring, report
169
192
  src/evals-ledger.ts the ledger and a naive overwrite store behind the harness interface
193
+ src/evals-file.ts scenario files, validated; signed eval reports and their verification
170
194
  src/index.ts public surface
171
195
  examples/ runnable walkthroughs, each ends with OK and is run by the test suite
172
196
  test/ one test per question a platform owner asks after a memory incident
@@ -177,10 +201,12 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
177
201
 
178
202
  **Done**
179
203
 
180
- - Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
204
+ - Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL or SQLite behind one store interface, with export back to JSONL.
181
205
  - The memory server: the ledger as MCP tools behind the receipts gateway, with the source receipt id and the attested actor supplied by the gateway, policy over spaces, and a denial receipt for every refused write.
182
206
  - Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
183
207
  - Retention windows per space in the server, sweeps that default to them, and a `sweep --via` trigger that runs retention through a gateway as a named principal, on any timer.
208
+ - The custody pack: a fact's history with receipts, holds, blast radius, and forget certificate as one signed artefact, verified as a whole.
209
+ - Removal verification: after a retraction, forget, or sweep the server asks each store's search whether the value still surfaces and records verified, stillIndexed, unverified, or failed per store, in the receipt.
184
210
  - Retention and legal hold: a receipted sweep forgets what was learned before an instant and reaches the stores; a hold refuses forget and sweep until released, as events on the fact's history.
185
211
  - Value-level quarantine: a write may cite a fact the gateway fetched itself; the value must match it and the fact is then `verified`, the provenance level above attested, or the write is refused.
186
212
  - Attested executions: with a key, the memory server signs its results for the gateway's receipt, so a verifier holding its public key sees memory calls as attested.
@@ -189,13 +215,13 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
189
215
  - Policy over provenance: the gateway looks up the fact a write supersedes or a retraction targets, so policy decides on its space, actor, and provenance; a claimed fact can be displaced, an attested org fact cannot.
190
216
  - Consumed facts and blast radius: the memory server declares the facts it serves, the gateway records them on every later receipt, and `blast` walks from a fact to every downstream call and derived belief, transitively, with its retraction status.
191
217
  - Write-through adapters for Mem0 and Zep: every write lands in the store with custody metadata, the store id is recorded on the fact, retractions reach the store, and failures are ordered so nothing is half-recorded.
218
+ - The eval CLI: built-in or custom scenario files, the naive baseline beside the ledger, a signed report a reviewer verifies, and a non-zero exit on regression, for cron.
192
219
  - The memory-mutation eval harness: stale reads, contradictions, blast radius, and correct reads over scripted incidents, scored the same way for the ledger and for anything behind the same interface.
193
220
  - Quarantine: facts carry `attested` or `claimed` provenance; claimed facts are hidden from reads by default and a gateway-only `memory.confirm` lifts them, as a recorded event.
194
221
 
195
222
  **Next, in the order it pays off**
196
223
 
197
- 1. A storage interface for the ledger with SQLite as the first alternative to JSONL, for durability, concurrent readers, and indexed queries at scale; JSONL stays the default and the auditable export. [Issue #1](https://github.com/ch4r10t33r/agent-custody/issues/1).
224
+ 1. Indexed queries on the SQLite store, so a shared ledger with millions of events answers reads without loading them all. [Issue #8](https://github.com/ch4r10t33r/agent-custody/issues/8).
198
225
  2. Write-through adapters for Letta, LangMem, and Cognee, one per user who asks. [Issue #4](https://github.com/ch4r10t33r/agent-custody/issues/4).
199
- 3. The eval harness as a CLI with customer scenario files and a signed report. [Issue #5](https://github.com/ch4r10t33r/agent-custody/issues/5).
200
- 4. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
226
+ 3. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane, with SSO, SCIM, residency, and SIEM export. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
201
227
 
package/dist/cli.js CHANGED
@@ -4,7 +4,14 @@ import { Ledger } from "./ledger.js";
4
4
  import { createMemoryServer, serveStdio } from "./server.js";
5
5
  import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
6
6
  import { serveMemoryHttp } from "./http.js";
7
- import { loadPrivateKey } from "@agent-custody/receipts";
7
+ import { loadPrivateKey, loadPublicKey } from "@agent-custody/receipts";
8
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { formatReport, runAll, SCENARIOS } from "./evals.js";
12
+ import { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
13
+ import { loadScenarios, signReport, verifyReport } from "./evals-file.js";
14
+ import { buildPack, formatPack, signPack, verifyPack } from "./pack.js";
8
15
  import { fileURLToPath } from "node:url";
9
16
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
17
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -30,7 +37,7 @@ function parseRetention(spec) {
30
37
  }
31
38
  const USAGE = `agent-custody-memory <command>
32
39
 
33
- serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>] [--forget-key-env NAME] [--retention 'org=P365D,team:*=P90D']
40
+ serve --ledger <ledger.jsonl|ledger.sqlite> [--allow-direct] [--key <memory.key>] [--forget-key-env NAME] [--retention 'org=P365D,team:*=P90D']
34
41
  --forget-key-env names a secret kept outside the ledger; forgotten values then leave an HMAC, not a guessable hash.
35
42
  the memory server over stdio; run it as the receipts gateway's upstream.
36
43
  --key signs every result for its receipt, so executions verify as attested by this server.
@@ -42,6 +49,16 @@ const USAGE = `agent-custody-memory <command>
42
49
  Without --before, the memory server's --retention windows decide. Put this on a timer.
43
50
  sweep --ledger <ledger.jsonl> --before <ISO instant> [--space <space>] --reason <text> [--actor <id>] [--forget-key-env NAME] [--no-digest]
44
51
  retention on the ledger file alone, for ledgers with no gateway or stores in front of them.
52
+ eval [--scenarios <file.json>] [--baseline] [--json] [--sign <key> --out <report.json>]
53
+ runs the memory-mutation scenarios on a fresh ledger; --baseline also scores a naive
54
+ overwrite store; --sign writes a signed report. Exits 1 if the ledger regresses.
55
+ eval --verify <report.json> --key <pub> checks a signed report and prints its scores
56
+ pack --ledger <ledger> --receipts <dir> --fact <factId> --out <pack.json> --sign <key>
57
+ everything about one fact as one signed artefact: history with receipts, blast radius,
58
+ holds, the forget certificate and what the stores answered. For counsel and auditors.
59
+ pack --verify <pack.json> --key <pub> [--issuer-key <pub>] [--principal-key <pub>]
60
+ checks the pack's signature and every receipt inside it
61
+ export --ledger <ledger.sqlite> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line
45
62
  blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
46
63
  everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
47
64
  `;
@@ -53,6 +70,8 @@ async function main(argv) {
53
70
  if (!values.ledger)
54
71
  throw new Error("serve needs --ledger");
55
72
  const ledger = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"]));
73
+ // Printed after the startup line, which supervisors and tests read first for the address.
74
+ const digestWarning = values["forget-key-env"] ? "" : "agent-custody-memory: no --forget-key-env; forgotten values leave a plain sha256, which is guessable for short values. Set a forget key, or forget with keepDigest false.";
56
75
  const identity = values.key ? loadPrivateKey(values.key) : undefined;
57
76
  const retention = values.retention ? parseRetention(values.retention) : undefined;
58
77
  const common = { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}), ...(retention ? { retention } : {}) };
@@ -62,11 +81,15 @@ async function main(argv) {
62
81
  throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
63
82
  const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, ...common, ...(token ? { tokens: [token] } : {}) });
64
83
  console.error(`agent-custody-memory: ${running.url} ledger=${values.ledger} events=${ledger.size} ${token ? "bearer token required" : "open"} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
84
+ if (digestWarning)
85
+ console.error(digestWarning);
65
86
  await new Promise((resolve) => process.once("SIGINT", resolve));
66
87
  await running.close();
67
88
  return 0;
68
89
  }
69
90
  console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.size} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
91
+ if (digestWarning)
92
+ console.error(digestWarning);
70
93
  await serveStdio(createMemoryServer(ledger, common));
71
94
  return 0;
72
95
  }
@@ -98,6 +121,78 @@ async function main(argv) {
98
121
  console.log(` ${f.factId} ${f.digestKind}${f.valueDigest ? ` ${f.valueDigest.slice(0, 12)}` : ""}`);
99
122
  return 0;
100
123
  }
124
+ case "eval": {
125
+ const { values } = parseArgs({ args: rest, options: { scenarios: { type: "string" }, baseline: { type: "boolean", default: false }, json: { type: "boolean", default: false }, sign: { type: "string" }, out: { type: "string" }, verify: { type: "string" }, key: { type: "string", multiple: true } } });
126
+ if (values.verify) {
127
+ if (!values.key?.length)
128
+ throw new Error("eval --verify needs --key <pub>");
129
+ const r = verifyReport(JSON.parse(readFileSync(values.verify, "utf8")), values.key.map(loadPublicKey));
130
+ if (!r.ok) {
131
+ console.log(`NOT VERIFIED: ${r.error}`);
132
+ return 1;
133
+ }
134
+ console.log(`VERIFIED signed by ${r.keyid.slice(0, 12)} system ${r.predicate.system} ran ${r.predicate.ranAt} scenarios ${r.predicate.scenarioNames.length} (digest ${r.predicate.scenariosDigest.slice(0, 12)})`);
135
+ console.log(formatReport(r.predicate.report));
136
+ return 0;
137
+ }
138
+ const scenarios = values.scenarios ? loadScenarios(values.scenarios) : SCENARIOS;
139
+ const ledger = new Ledger(join(mkdtempSync(join(tmpdir(), "agent-custody-eval-")), "ledger.jsonl"));
140
+ const report = await runAll(ledgerUnderTest(ledger), scenarios);
141
+ const baseline = values.baseline ? await runAll(overwriteStoreUnderTest(), scenarios) : null;
142
+ const regressed = report.totals.correctReads < report.totals.reads || report.scenarios.some((s) => s.failures.length > 0);
143
+ if (values.json)
144
+ console.log(JSON.stringify({ ledger: report, baseline }, null, 2));
145
+ else {
146
+ console.log("The ledger:");
147
+ console.log(formatReport(report));
148
+ if (baseline) {
149
+ console.log("\nA naive overwrite store:");
150
+ console.log(formatReport(baseline));
151
+ }
152
+ }
153
+ if (values.sign) {
154
+ if (!values.out)
155
+ throw new Error("eval --sign needs --out <report.json>");
156
+ writeFileSync(values.out, JSON.stringify(signReport("agent-custody-ledger", scenarios, report, loadPrivateKey(values.sign)), null, 2));
157
+ console.error(`signed report written to ${values.out}`);
158
+ }
159
+ return regressed ? 1 : 0;
160
+ }
161
+ case "pack": {
162
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, out: { type: "string" }, sign: { type: "string" }, verify: { type: "string" }, key: { type: "string", multiple: true }, "issuer-key": { type: "string", multiple: true }, "principal-key": { type: "string", multiple: true }, json: { type: "boolean", default: false } } });
163
+ if (values.verify) {
164
+ if (!values.key?.length)
165
+ throw new Error("pack --verify needs --key <pub>");
166
+ const r = verifyPack(JSON.parse(readFileSync(values.verify, "utf8")), values.key.map(loadPublicKey), values["issuer-key"]?.length ? { issuerKeys: values["issuer-key"].map(loadPublicKey), principalKeys: (values["principal-key"] ?? []).map(loadPublicKey) } : undefined);
167
+ if (values.json)
168
+ console.log(JSON.stringify(r, null, 2));
169
+ else {
170
+ for (const c of r.checks)
171
+ console.log(`${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
172
+ console.log(`\nRESULT: ${r.ok ? "VERIFIED" : "NOT VERIFIED"}`);
173
+ if (r.pack)
174
+ console.log("\n" + formatPack(r.pack));
175
+ }
176
+ return r.ok ? 0 : 1;
177
+ }
178
+ if (!values.ledger || !values.receipts || !values.fact || !values.out || !values.sign)
179
+ throw new Error("pack needs --ledger, --receipts, --fact, --out, and --sign");
180
+ const pack = buildPack(new Ledger(values.ledger), values.receipts, values.fact);
181
+ writeFileSync(values.out, JSON.stringify(signPack(pack, loadPrivateKey(values.sign)), null, 2));
182
+ console.log(formatPack(pack));
183
+ console.error(`signed pack written to ${values.out}`);
184
+ return pack.missingReceipts.length ? 1 : 0;
185
+ }
186
+ case "export": {
187
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, out: { type: "string" } } });
188
+ if (!values.ledger || !values.out)
189
+ throw new Error("export needs --ledger and --out");
190
+ const l = new Ledger(values.ledger);
191
+ writeFileSync(values.out, l.export().map((e) => JSON.stringify(e)).join("\n") + "\n");
192
+ console.log(`exported ${l.size} event(s) from ${l.location} to ${values.out}`);
193
+ l.close();
194
+ return 0;
195
+ }
101
196
  case "blast": {
102
197
  const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, json: { type: "boolean", default: false } } });
103
198
  if (!values.ledger || !values.receipts || !values.fact)
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { type Envelope, type KeyPair, type PublicKeyRef } from "@agent-custody/receipts";
3
+ import type { Report, Scenario } from "./evals.ts";
4
+ export declare const EVAL_REPORT_TYPE = "https://agent-custody.dev/eval-report/v0.1";
5
+ export declare const ScenarioFileSchema: z.ZodObject<{
6
+ version: z.ZodLiteral<"0.1">;
7
+ scenarios: z.ZodArray<z.ZodObject<{
8
+ name: z.ZodString;
9
+ ops: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
10
+ op: z.ZodLiteral<"write">;
11
+ key: z.ZodString;
12
+ subject: z.ZodString;
13
+ predicate: z.ZodString;
14
+ value: z.ZodUnknown;
15
+ space: z.ZodOptional<z.ZodString>;
16
+ actor: z.ZodOptional<z.ZodString>;
17
+ supersedes: z.ZodOptional<z.ZodString>;
18
+ bad: z.ZodOptional<z.ZodBoolean>;
19
+ }, z.core.$strip>, z.ZodObject<{
20
+ op: z.ZodLiteral<"read">;
21
+ subject: z.ZodString;
22
+ predicate: z.ZodOptional<z.ZodString>;
23
+ space: z.ZodOptional<z.ZodString>;
24
+ expect: z.ZodUnknown;
25
+ }, z.core.$strip>, z.ZodObject<{
26
+ op: z.ZodLiteral<"retract">;
27
+ key: z.ZodString;
28
+ actor: z.ZodOptional<z.ZodString>;
29
+ reason: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>], "op">>;
31
+ }, z.core.$strip>>;
32
+ }, z.core.$strip>;
33
+ export type ScenarioFile = z.infer<typeof ScenarioFileSchema>;
34
+ /** Loads and validates a scenario file. Every read must carry `expect`, null meaning "nothing". */
35
+ export declare function loadScenarios(path: string): Scenario[];
36
+ export interface EvalReportPredicate {
37
+ system: string;
38
+ scenariosDigest: string;
39
+ scenarioNames: string[];
40
+ report: Report;
41
+ ranAt: string;
42
+ }
43
+ /** An in-toto statement over the report, signed with the given key: the artefact a reviewer verifies. */
44
+ export declare function signReport(system: string, scenarios: Scenario[], report: Report, key: KeyPair): Envelope;
45
+ export type ReportCheck = {
46
+ ok: true;
47
+ predicate: EvalReportPredicate;
48
+ keyid: string;
49
+ } | {
50
+ ok: false;
51
+ error: string;
52
+ };
53
+ export declare function verifyReport(envelope: Envelope, keys: PublicKeyRef[]): ReportCheck;
@@ -0,0 +1,43 @@
1
+ // Scenario files and signed eval reports. A team encodes its own memory incidents as scenarios, runs them on a
2
+ // schedule, and hands a reviewer a report signed with a key, verifiable with the same envelope format as receipts.
3
+ import { readFileSync } from "node:fs";
4
+ import { z } from "zod";
5
+ import { digestOf, dsseSign, dsseVerify } from "@agent-custody/receipts";
6
+ export const EVAL_REPORT_TYPE = "https://agent-custody.dev/eval-report/v0.1";
7
+ const OpSchema = z.discriminatedUnion("op", [
8
+ z.object({ op: z.literal("write"), key: z.string().min(1), subject: z.string().min(1), predicate: z.string().min(1), value: z.unknown(), space: z.string().min(1).optional(), actor: z.string().min(1).optional(), supersedes: z.string().min(1).optional(), bad: z.boolean().optional() }),
9
+ z.object({ op: z.literal("read"), subject: z.string().min(1), predicate: z.string().min(1).optional(), space: z.string().min(1).optional(), expect: z.unknown() }),
10
+ z.object({ op: z.literal("retract"), key: z.string().min(1), actor: z.string().min(1).optional(), reason: z.string().min(1).optional() }),
11
+ ]);
12
+ export const ScenarioFileSchema = z.object({
13
+ version: z.literal("0.1"),
14
+ scenarios: z.array(z.object({ name: z.string().min(1), ops: z.array(OpSchema).min(1) })).min(1),
15
+ });
16
+ /** Loads and validates a scenario file. Every read must carry `expect`, null meaning "nothing". */
17
+ export function loadScenarios(path) {
18
+ const parsed = ScenarioFileSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
19
+ if (!parsed.success)
20
+ throw new Error(`scenario file ${path}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
21
+ for (const [si, s] of parsed.data.scenarios.entries())
22
+ for (const [oi, o] of s.ops.entries())
23
+ if (o.op === "read" && !("expect" in o))
24
+ throw new Error(`scenario file ${path}: scenarios.${si}.ops.${oi}: a read needs expect (null for nothing)`);
25
+ return parsed.data.scenarios;
26
+ }
27
+ /** An in-toto statement over the report, signed with the given key: the artefact a reviewer verifies. */
28
+ export function signReport(system, scenarios, report, key) {
29
+ const predicate = { system, scenariosDigest: digestOf(scenarios), scenarioNames: scenarios.map((s) => s.name), report, ranAt: new Date().toISOString() };
30
+ const statement = { _type: "https://in-toto.io/Statement/v1", subject: [{ name: `eval:${system}`, digest: { sha256: digestOf(report) } }], predicateType: EVAL_REPORT_TYPE, predicate };
31
+ return dsseSign("application/vnd.in-toto+json", statement, key);
32
+ }
33
+ export function verifyReport(envelope, keys) {
34
+ const v = dsseVerify(envelope, keys);
35
+ if (!v.ok)
36
+ return { ok: false, error: v.error };
37
+ const st = v.payload;
38
+ if (st.predicateType !== EVAL_REPORT_TYPE || !st.predicate)
39
+ return { ok: false, error: `not an eval report: ${String(st.predicateType)}` };
40
+ if (st.subject?.[0]?.digest?.sha256 !== digestOf(st.predicate.report))
41
+ return { ok: false, error: "report digest does not match the subject" };
42
+ return { ok: true, predicate: st.predicate, keyid: v.keyid };
43
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  export { Ledger } from "./ledger.ts";
2
+ export { JsonlStore, SqliteStore, openStore } from "./storage.ts";
3
+ export type { EventStore } from "./storage.ts";
2
4
  export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, DigestKind, ForgetEvent, ForgetInput, HoldEvent, HoldInput, LedgerEvent, SweepInput, RetractEvent, RetractInput, Source } from "./ledger.ts";
3
5
  export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.ts";
4
6
  export type { MemoryServerOptions } from "./server.ts";
5
7
  export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.ts";
6
8
  export type { MemoryUnderTest, Op, Report, Scenario, ScenarioScore } from "./evals.ts";
7
9
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.ts";
10
+ export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.ts";
11
+ export type { EvalReportPredicate, ReportCheck, ScenarioFile } from "./evals-file.ts";
8
12
  export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
9
13
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
10
14
  export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
15
+ export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.ts";
16
+ export type { CustodyPack, PackCheck, PackVerification } from "./pack.ts";
11
17
  export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
12
18
  export type { BlastRadius, ReceiptSummary } from "./blast.ts";
13
- export type { Mem0Like, Mem0Options, Store, ZepLike, ZepOptions } from "./stores.ts";
19
+ export type { Mem0Like, Mem0Options, RemovalOutcome, Store, ZepLike, ZepOptions } from "./stores.ts";
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // Public surface of @agent-custody/state.
2
2
  export { Ledger } from "./ledger.js";
3
+ export { JsonlStore, SqliteStore, openStore } from "./storage.js";
3
4
  export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.js";
4
5
  export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
5
6
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
7
+ export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verifyReport } from "./evals-file.js";
6
8
  export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
7
9
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
8
10
  export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
11
+ export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.js";
package/dist/ledger.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type EventStore } from "./storage.ts";
1
2
  /** Where a write came from. A receipt id means the write went through a receipts producer and can be verified there. */
2
3
  export interface Source {
3
4
  receiptId: string | null;
@@ -155,14 +156,22 @@ export interface AsOf {
155
156
  }
156
157
  export declare class Ledger {
157
158
  private readonly events;
158
- private readonly file;
159
+ private readonly store;
159
160
  private readonly now;
160
161
  private readonly forgetKey;
161
- /** forgetKey: a secret kept outside the file; with it, forgotten values leave an HMAC rather than a plain hash. */
162
- constructor(file: string, opts?: {
162
+ /**
163
+ * `location` is a path: JSONL by default, SQLite when it ends in .sqlite or .db; or pass a store.
164
+ * forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a plain hash.
165
+ */
166
+ constructor(location: string | EventStore, opts?: {
163
167
  now?: () => Date;
164
168
  forgetKey?: string | Buffer;
165
169
  });
170
+ /** Where the events live, for reports. */
171
+ get location(): string;
172
+ /** Every event in order, for export. */
173
+ export(): LedgerEvent[];
174
+ close(): void;
166
175
  get size(): number;
167
176
  /** The checks assert makes, without appending. For callers that must do something irreversible before the append. */
168
177
  validateAssert(input: AssertInput): void;
package/dist/ledger.js CHANGED
@@ -3,28 +3,33 @@
3
3
  // Nothing is ever edited in place. Correcting a belief is a new event, so "what did the agent believe at T" is always answerable.
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { createHash, createHmac } from "node:crypto";
6
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
- import { dirname } from "node:path";
6
+ import { openStore } from "./storage.js";
8
7
  const RANK = { claimed: 0, attested: 1, verified: 2 };
9
8
  export class Ledger {
10
- events = [];
11
- file;
9
+ events;
10
+ store;
12
11
  now;
13
12
  forgetKey;
14
- /** forgetKey: a secret kept outside the file; with it, forgotten values leave an HMAC rather than a plain hash. */
15
- constructor(file, opts = {}) {
16
- this.file = file;
13
+ /**
14
+ * `location` is a path: JSONL by default, SQLite when it ends in .sqlite or .db; or pass a store.
15
+ * forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a plain hash.
16
+ */
17
+ constructor(location, opts = {}) {
18
+ this.store = typeof location === "string" ? openStore(location) : location;
17
19
  this.now = opts.now ?? (() => new Date());
18
20
  this.forgetKey = opts.forgetKey ? Buffer.from(opts.forgetKey) : null;
19
- if (existsSync(file)) {
20
- for (const line of readFileSync(file, "utf8").split("\n")) {
21
- if (line.trim())
22
- this.events.push(JSON.parse(line));
23
- }
24
- }
25
- else {
26
- mkdirSync(dirname(file), { recursive: true });
27
- }
21
+ this.events = this.store.load();
22
+ }
23
+ /** Where the events live, for reports. */
24
+ get location() {
25
+ return this.store.location;
26
+ }
27
+ /** Every event in order, for export. */
28
+ export() {
29
+ return [...this.events];
30
+ }
31
+ close() {
32
+ this.store.close();
28
33
  }
29
34
  get size() {
30
35
  return this.events.length;
@@ -162,13 +167,11 @@ export class Ledger {
162
167
  if (e.kind === "assert" && e.fact.factId === input.factId) {
163
168
  e.fact.value = null;
164
169
  e.fact.forgotten = { valueDigest, digestKind, at: txTime };
170
+ this.store.replaceAssert(e);
165
171
  }
166
172
  }
167
173
  const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest, digestKind };
168
- this.events.push(event);
169
- const tmp = `${this.file}.tmp`;
170
- writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
171
- renameSync(tmp, this.file);
174
+ this.append(event);
172
175
  return event;
173
176
  }
174
177
  /** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
@@ -228,7 +231,7 @@ export class Ledger {
228
231
  return this.events.some((e) => (e.kind === "retract" || e.kind === "forget") && e.factId === factId);
229
232
  }
230
233
  append(event) {
231
- appendFileSync(this.file, JSON.stringify(event) + "\n");
234
+ this.store.append(event);
232
235
  this.events.push(event);
233
236
  }
234
237
  }
package/dist/pack.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { type Envelope, type KeyPair, type PublicKeyRef, type ReceiptBundle } from "@agent-custody/receipts";
2
+ import { type BlastRadius } from "./blast.ts";
3
+ import type { Fact, Ledger, LedgerEvent } from "./ledger.ts";
4
+ export declare const PACK_TYPE = "https://agent-custody.dev/custody-pack/v0.1";
5
+ export interface CustodyPack {
6
+ version: "0.1";
7
+ generatedAt: string;
8
+ factId: string;
9
+ fact: Fact | null;
10
+ /** every event that touched the fact, oldest first */
11
+ history: LedgerEvent[];
12
+ /** every receipt referenced by the history or the blast radius, by id, as the full verifiable bundle */
13
+ receipts: Record<string, ReceiptBundle>;
14
+ /** receipt ids the history or blast radius cited but no bundle was found for */
15
+ missingReceipts: string[];
16
+ blast: BlastRadius;
17
+ /** the forget event and what the forget receipt recorded, when the fact was forgotten */
18
+ forget: {
19
+ event: LedgerEvent;
20
+ receiptId: string | null;
21
+ verification: Record<string, string> | null;
22
+ removedFrom: string[] | null;
23
+ } | null;
24
+ /** hold and release events, in order */
25
+ holds: LedgerEvent[];
26
+ }
27
+ export declare function buildPack(ledger: Ledger, receiptsDir: string, factId: string): CustodyPack;
28
+ export declare function signPack(pack: CustodyPack, key: KeyPair): Envelope;
29
+ export interface PackCheck {
30
+ name: string;
31
+ ok: boolean;
32
+ detail?: string;
33
+ }
34
+ export interface PackVerification {
35
+ ok: boolean;
36
+ checks: PackCheck[];
37
+ pack: CustodyPack | null;
38
+ }
39
+ /**
40
+ * Verifies the pack's own signature and digest, then every receipt inside it against the gateway's keys when given,
41
+ * that every event's source receipt is present, and that the forget receipt's result names this fact.
42
+ */
43
+ export declare function verifyPack(envelope: Envelope, packKeys: PublicKeyRef[], receiptKeys?: {
44
+ issuerKeys: PublicKeyRef[];
45
+ principalKeys?: PublicKeyRef[];
46
+ logKeys?: PublicKeyRef[];
47
+ upstreamKeys?: PublicKeyRef[];
48
+ }): PackVerification;
49
+ export declare function formatPack(p: CustodyPack): string;
package/dist/pack.js ADDED
@@ -0,0 +1,148 @@
1
+ // The custody pack: everything about one fact as a single artefact a reviewer can verify. Its history with the
2
+ // receipt that produced each event, its blast radius with every downstream receipt, its forget certificate and what
3
+ // the stores answered, all inside one signed statement. Hand it to counsel, an auditor, or the person who asked for
4
+ // the deletion; they need the signing key and the gateway's key, and nothing from you.
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { digestOf, dsseSign, dsseVerify, verifyBundle } from "@agent-custody/receipts";
8
+ import { blastRadius, loadReceipts } from "./blast.js";
9
+ export const PACK_TYPE = "https://agent-custody.dev/custody-pack/v0.1";
10
+ function loadBundles(dir) {
11
+ const out = new Map();
12
+ for (const f of readdirSync(dir)) {
13
+ if (!f.endsWith(".json"))
14
+ continue;
15
+ try {
16
+ const bundle = JSON.parse(readFileSync(join(dir, f), "utf8"));
17
+ const st = JSON.parse(Buffer.from(bundle.envelope.payload, "base64").toString());
18
+ if (st.predicate?.receiptId)
19
+ out.set(st.predicate.receiptId, bundle);
20
+ }
21
+ catch {
22
+ // not a bundle; skip
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+ function receiptResult(bundle) {
28
+ try {
29
+ const st = JSON.parse(Buffer.from(bundle.envelope.payload, "base64").toString());
30
+ const text = st.predicate?.execution?.result?.content?.find((c) => c.type === "text")?.text;
31
+ if (!text)
32
+ return null;
33
+ try {
34
+ return JSON.parse(text);
35
+ }
36
+ catch {
37
+ return text;
38
+ }
39
+ }
40
+ catch {
41
+ return null; // a damaged bundle; the receipt check reports it
42
+ }
43
+ }
44
+ export function buildPack(ledger, receiptsDir, factId) {
45
+ const bundles = loadBundles(receiptsDir);
46
+ const history = ledger.history(factId);
47
+ const blast = blastRadius(ledger, loadReceipts(receiptsDir), factId);
48
+ const wanted = new Set();
49
+ for (const e of history) {
50
+ const src = e.kind === "assert" ? e.fact.source.receiptId : e.source.receiptId;
51
+ if (src)
52
+ wanted.add(src);
53
+ }
54
+ for (const r of blast.receipts)
55
+ wanted.add(r.receiptId);
56
+ for (const f of blast.derivedFacts)
57
+ if (f.source.receiptId)
58
+ wanted.add(f.source.receiptId);
59
+ const receipts = {};
60
+ const missingReceipts = [];
61
+ for (const id of [...wanted].sort()) {
62
+ const b = bundles.get(id);
63
+ if (b)
64
+ receipts[id] = b;
65
+ else
66
+ missingReceipts.push(id);
67
+ }
68
+ const forgetEvent = history.find((e) => e.kind === "forget");
69
+ let forget = null;
70
+ if (forgetEvent && forgetEvent.kind === "forget") {
71
+ const rid = forgetEvent.source.receiptId;
72
+ const result = rid && receipts[rid] ? receiptResult(receipts[rid]) : null;
73
+ forget = { event: forgetEvent, receiptId: rid, verification: result?.verification ?? null, removedFrom: result?.removedFrom ?? null };
74
+ }
75
+ return {
76
+ version: "0.1",
77
+ generatedAt: new Date().toISOString(),
78
+ factId,
79
+ fact: blast.fact,
80
+ history,
81
+ receipts,
82
+ missingReceipts,
83
+ blast,
84
+ forget,
85
+ holds: history.filter((e) => e.kind === "hold" || e.kind === "release"),
86
+ };
87
+ }
88
+ export function signPack(pack, key) {
89
+ const statement = { _type: "https://in-toto.io/Statement/v1", subject: [{ name: `custody-pack:${pack.factId}`, digest: { sha256: digestOf(pack) } }], predicateType: PACK_TYPE, predicate: pack };
90
+ return dsseSign("application/vnd.in-toto+json", statement, key);
91
+ }
92
+ /**
93
+ * Verifies the pack's own signature and digest, then every receipt inside it against the gateway's keys when given,
94
+ * that every event's source receipt is present, and that the forget receipt's result names this fact.
95
+ */
96
+ export function verifyPack(envelope, packKeys, receiptKeys) {
97
+ const checks = [];
98
+ const add = (name, ok, detail) => {
99
+ checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
100
+ return ok;
101
+ };
102
+ const v = dsseVerify(envelope, packKeys);
103
+ if (!v.ok) {
104
+ add("pack signature", false, v.error);
105
+ return { ok: false, checks, pack: null };
106
+ }
107
+ add("pack signature", true, `keyid ${v.keyid.slice(0, 12)}`);
108
+ const st = v.payload;
109
+ if (!add("pack type", st.predicateType === PACK_TYPE && !!st.predicate))
110
+ return { ok: false, checks, pack: null };
111
+ const pack = st.predicate;
112
+ add("pack digest", st.subject?.[0]?.digest?.sha256 === digestOf(pack));
113
+ add("no receipts missing", pack.missingReceipts.length === 0, pack.missingReceipts.length ? `${pack.missingReceipts.length} cited receipt(s) absent` : undefined);
114
+ for (const e of pack.history) {
115
+ const src = e.kind === "assert" ? e.fact.source.receiptId : e.source.receiptId;
116
+ if (src)
117
+ add(`${e.kind} event cites a receipt in the pack`, src in pack.receipts, src.slice(0, 8));
118
+ }
119
+ if (receiptKeys) {
120
+ for (const [id, bundle] of Object.entries(pack.receipts)) {
121
+ const r = verifyBundle(bundle, { issuerKeys: receiptKeys.issuerKeys, principalKeys: receiptKeys.principalKeys ?? [], ...(receiptKeys.logKeys ? { logKeys: receiptKeys.logKeys } : {}), ...(receiptKeys.upstreamKeys ? { upstreamKeys: receiptKeys.upstreamKeys } : {}) });
122
+ add(`receipt ${id.slice(0, 8)} verifies`, r.ok, r.ok ? undefined : r.checks.filter((c) => !c.ok).map((c) => c.name).join(", "));
123
+ if (r.ok && r.statement && r.statement.predicate.receiptId !== id)
124
+ add(`receipt ${id.slice(0, 8)} is the receipt it claims to be`, false);
125
+ }
126
+ }
127
+ else {
128
+ add("receipts checked against the gateway's keys", false, "no issuer key given; pass --issuer-key to check the receipts inside the pack");
129
+ }
130
+ if (pack.forget) {
131
+ const bundle = pack.forget.receiptId ? pack.receipts[pack.forget.receiptId] : undefined;
132
+ const result = bundle ? receiptResult(bundle) : null;
133
+ add("forget receipt names this fact and records the erasure", !!result && result.factId === pack.factId && result.erasedFromLedger === true, bundle ? undefined : "forget receipt not in the pack");
134
+ }
135
+ return { ok: checks.every((c) => c.ok), checks, pack };
136
+ }
137
+ export function formatPack(p) {
138
+ const lines = [];
139
+ lines.push(p.fact ? `fact ${p.factId}: ${p.fact.subject} ${p.fact.predicate}${p.fact.forgotten ? " = (erased)" : ` = ${JSON.stringify(p.fact.value)}`} (space ${p.fact.space}, by ${p.fact.actor}, ${p.fact.provenance})` : `fact ${p.factId}: not in this ledger`);
140
+ lines.push(`history: ${p.history.map((e) => e.kind).join(" → ")}`);
141
+ lines.push(`receipts in pack: ${Object.keys(p.receipts).length}${p.missingReceipts.length ? `, missing ${p.missingReceipts.length}` : ""}`);
142
+ if (p.holds.length)
143
+ lines.push(`holds: ${p.holds.map((e) => (e.kind === "hold" || e.kind === "release" ? `${e.kind} by ${e.actor} (${e.reason})` : e.kind)).join("; ")}`);
144
+ if (p.forget)
145
+ lines.push(`forgotten at ${p.forget.event.txTime} by ${p.forget.event.actor}; receipt ${p.forget.receiptId?.slice(0, 8) ?? "none"}; stores: ${p.forget.verification ? Object.entries(p.forget.verification).map(([k, v]) => `${k}=${v}`).join(", ") : "none"}`);
146
+ lines.push(`blast radius: ${p.blast.receipts.length} later call(s), ${p.blast.derivedFacts.length} derived belief(s), ${p.blast.stillBelieved.length} still believed`);
147
+ return lines.join("\n");
148
+ }
package/dist/server.d.ts CHANGED
@@ -21,6 +21,11 @@ export interface MemoryServerOptions {
21
21
  identity?: KeyPair;
22
22
  /** Retention windows by space pattern, ISO 8601 durations: { "org": "P365D", "team:*": "P90D" }. memory.sweep without `before` uses them. */
23
23
  retention?: Record<string, string>;
24
+ /** After a removal, how many times to ask a store's search whether the value is gone, and the first wait between asks (doubling). Default 3 and 200 ms. */
25
+ verify?: {
26
+ attempts?: number;
27
+ delayMs?: number;
28
+ };
24
29
  }
25
30
  /** Parses the ISO 8601 duration subset retention needs: P<n>W, P<n>D, PT<n>H, and combinations of D and H. */
26
31
  export declare function durationMs(iso: string): number;
package/dist/server.js CHANGED
@@ -119,11 +119,16 @@ export function createMemoryServer(ledger, opts = {}) {
119
119
  const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
120
120
  return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
121
121
  });
122
- async function forgetOne(factId, reason, keepDigest) {
123
- const fact = ledger.facts().find((f) => f.factId === factId);
124
- const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt }, ...(keepDigest === undefined ? {} : { keepDigest }) });
122
+ /**
123
+ * Removes a fact from every store it was written to, then asks each store's search whether it is really gone.
124
+ * The outcome per store is what the receipt records: verified, stillIndexed, unverified (the store cannot say), or failed.
125
+ */
126
+ async function removeFromStores(fact) {
125
127
  const removedFrom = [];
126
128
  const stillHeld = [];
129
+ const verification = {};
130
+ const attempts = Math.max(1, opts.verify?.attempts ?? 3);
131
+ const delayMs = opts.verify?.delayMs ?? 200;
127
132
  for (const store of opts.stores ?? []) {
128
133
  const id = fact?.external?.[store.name];
129
134
  if (!id)
@@ -134,9 +139,36 @@ export function createMemoryServer(ledger, opts = {}) {
134
139
  }
135
140
  catch (e) {
136
141
  stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
142
+ verification[store.name] = "failed";
143
+ continue;
144
+ }
145
+ if (!store.verifyRemoved) {
146
+ verification[store.name] = "unverified";
147
+ continue;
148
+ }
149
+ let gone = false;
150
+ let checked = true;
151
+ for (let i = 0; i < attempts && !gone; i++) {
152
+ if (i > 0)
153
+ await new Promise((r) => setTimeout(r, delayMs * 2 ** (i - 1)));
154
+ try {
155
+ gone = await store.verifyRemoved(id, fact);
156
+ }
157
+ catch {
158
+ // The store could not be asked; that is not the same as the value being gone.
159
+ checked = false;
160
+ break;
161
+ }
137
162
  }
163
+ verification[store.name] = !checked ? "unverified" : gone ? "verified" : "stillIndexed";
138
164
  }
139
- return { factId: ev.factId, valueDigest: ev.valueDigest, digestKind: ev.digestKind, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
165
+ return { removedFrom, stillHeld, verification };
166
+ }
167
+ async function forgetOne(factId, reason, keepDigest) {
168
+ const fact = ledger.facts().find((f) => f.factId === factId);
169
+ const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt }, ...(keepDigest === undefined ? {} : { keepDigest }) });
170
+ const { removedFrom, stillHeld, verification } = await removeFromStores(fact);
171
+ return { factId: ev.factId, valueDigest: ev.valueDigest, digestKind: ev.digestKind, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld, verification };
140
172
  }
141
173
  let currentActor = "anonymous";
142
174
  let currentReceipt = null;
@@ -188,19 +220,8 @@ export function createMemoryServer(ledger, opts = {}) {
188
220
  const ev = ledger.retract({ factId: a.factId, actor: actorFor(a.actor), reason: a.reason, source: { receiptId } });
189
221
  // The ledger is retracted first: custody must not depend on a store being up. A store that fails to remove
190
222
  // is reported, so the caller knows recall may still serve the value.
191
- const stillHeld = [];
192
- for (const store of opts.stores ?? []) {
193
- const id = fact?.external?.[store.name];
194
- if (!id)
195
- continue;
196
- try {
197
- await store.remove(id, fact);
198
- }
199
- catch (e) {
200
- stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
201
- }
202
- }
203
- const out = { eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, removedFrom: (opts.stores ?? []).map((s) => s.name).filter((n) => fact?.external?.[n] && !stillHeld.some((h) => h.startsWith(n))) };
223
+ const { removedFrom, stillHeld, verification } = await removeFromStores(fact);
224
+ const out = { eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, removedFrom, verification };
204
225
  if (stillHeld.length > 0)
205
226
  return { isError: true, content: [{ type: "text", text: `retracted in the ledger, but still held by ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
206
227
  return json(out);
@@ -250,7 +271,7 @@ export function createMemoryServer(ledger, opts = {}) {
250
271
  forgotten.push(await forgetOne(f.factId, a.reason, a.keepDigest));
251
272
  }
252
273
  const stillHeld = forgotten.flatMap((o) => o.stillHeld);
253
- const out = { before: a.before ?? null, retention: a.before ? null : (opts.retention ?? null), space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, digestKind: o.digestKind, removedFrom: o.removedFrom })), held, stillHeld };
274
+ const out = { before: a.before ?? null, retention: a.before ? null : (opts.retention ?? null), space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, digestKind: o.digestKind, removedFrom: o.removedFrom, verification: o.verification })), held, stillHeld };
254
275
  if (stillHeld.length > 0)
255
276
  return { isError: true, content: [{ type: "text", text: `swept the ledger, but some values are still held by stores: ${stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
256
277
  return json(out);
@@ -0,0 +1,32 @@
1
+ import type { AssertEvent, LedgerEvent } from "./ledger.ts";
2
+ export interface EventStore {
3
+ readonly kind: "jsonl" | "sqlite";
4
+ readonly location: string;
5
+ /** every event, in order */
6
+ load(): LedgerEvent[];
7
+ append(event: LedgerEvent): void;
8
+ /** replaces one assert event in place, for forget: the value is gone from the store, not merely superseded */
9
+ replaceAssert(event: AssertEvent): void;
10
+ close(): void;
11
+ }
12
+ export declare class JsonlStore implements EventStore {
13
+ readonly kind: "jsonl";
14
+ readonly location: string;
15
+ constructor(file: string);
16
+ load(): LedgerEvent[];
17
+ append(event: LedgerEvent): void;
18
+ replaceAssert(event: AssertEvent): void;
19
+ close(): void;
20
+ }
21
+ export declare class SqliteStore implements EventStore {
22
+ readonly kind: "sqlite";
23
+ readonly location: string;
24
+ private readonly db;
25
+ constructor(file: string);
26
+ load(): LedgerEvent[];
27
+ append(event: LedgerEvent): void;
28
+ replaceAssert(event: AssertEvent): void;
29
+ close(): void;
30
+ }
31
+ /** JSONL unless the path ends in .sqlite or .db. */
32
+ export declare function openStore(location: string): EventStore;
@@ -0,0 +1,67 @@
1
+ // Where the ledger's events live. The ledger keeps every event in memory for its queries; a store makes them durable.
2
+ // JSONL is the default and the auditable artefact: one event per line, readable by anyone, copied for an audit.
3
+ // SQLite is for durability and shared use: transactional writes, write-ahead logging, an in-place forget that leaves
4
+ // no copy of the value behind, and a file more than one process can open. Node ships the SQLite module; no native
5
+ // dependency.
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { dirname } from "node:path";
9
+ export class JsonlStore {
10
+ kind = "jsonl";
11
+ location;
12
+ constructor(file) {
13
+ this.location = file;
14
+ if (!existsSync(file))
15
+ mkdirSync(dirname(file), { recursive: true });
16
+ }
17
+ load() {
18
+ if (!existsSync(this.location))
19
+ return [];
20
+ return readFileSync(this.location, "utf8").split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
21
+ }
22
+ append(event) {
23
+ appendFileSync(this.location, JSON.stringify(event) + "\n");
24
+ }
25
+ replaceAssert(event) {
26
+ const all = this.load().map((e) => (e.kind === "assert" && e.eventId === event.eventId ? event : e));
27
+ const tmp = `${this.location}.tmp`;
28
+ writeFileSync(tmp, all.map((e) => JSON.stringify(e)).join("\n") + "\n");
29
+ renameSync(tmp, this.location);
30
+ }
31
+ close() { }
32
+ }
33
+ export class SqliteStore {
34
+ kind = "sqlite";
35
+ location;
36
+ db;
37
+ constructor(file) {
38
+ this.location = file;
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ // Loaded on demand so a JSONL ledger never touches the SQLite module, which warns on Node 22 that it is experimental.
41
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
42
+ this.db = new DatabaseSync(file);
43
+ // secure_delete overwrites removed content with zeros, so a forgotten value does not linger in freed page space.
44
+ this.db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;");
45
+ this.db.exec("CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, tx_time TEXT NOT NULL, fact_id TEXT NOT NULL, json TEXT NOT NULL)");
46
+ this.db.exec("CREATE INDEX IF NOT EXISTS events_fact ON events(fact_id); CREATE INDEX IF NOT EXISTS events_tx ON events(tx_time)");
47
+ }
48
+ load() {
49
+ return this.db.prepare("SELECT json FROM events ORDER BY seq").all().map((r) => JSON.parse(r.json));
50
+ }
51
+ append(event) {
52
+ const factId = event.kind === "assert" ? event.fact.factId : event.factId;
53
+ this.db.prepare("INSERT INTO events (event_id, kind, tx_time, fact_id, json) VALUES (?, ?, ?, ?, ?)").run(event.eventId, event.kind, event.txTime, factId, JSON.stringify(event));
54
+ }
55
+ replaceAssert(event) {
56
+ this.db.prepare("UPDATE events SET json = ? WHERE event_id = ?").run(JSON.stringify(event), event.eventId);
57
+ // The old row image would otherwise linger in the write-ahead log; a truncating checkpoint removes it.
58
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
59
+ }
60
+ close() {
61
+ this.db.close();
62
+ }
63
+ }
64
+ /** JSONL unless the path ends in .sqlite or .db. */
65
+ export function openStore(location) {
66
+ return /\.(sqlite|db)$/i.test(location) ? new SqliteStore(location) : new JsonlStore(location);
67
+ }
package/dist/stores.d.ts CHANGED
@@ -4,9 +4,16 @@ export interface Store {
4
4
  readonly name: string;
5
5
  /** writes the fact and returns the store's own id for it */
6
6
  put(fact: Fact): Promise<string>;
7
- /** removes the fact from the store; called on retraction */
7
+ /** removes the fact from the store; called on retraction and forget */
8
8
  remove(externalId: string, fact: Fact): Promise<void>;
9
+ /**
10
+ * Checks the store's own search no longer surfaces the fact: a delete by id and a search index catching up are
11
+ * different moments. Optional; a store without it is reported as unverified, never as verified.
12
+ */
13
+ verifyRemoved?(externalId: string, fact: Fact): Promise<boolean>;
9
14
  }
15
+ /** What the server reports per store after a removal. */
16
+ export type RemovalOutcome = "verified" | "stillIndexed" | "unverified" | "failed";
10
17
  /** One line a retrieval store can index: what the fact says, in words. */
11
18
  export declare function factText(f: Fact): string;
12
19
  /** Metadata every store receives alongside the text, so a memory can always be traced back to its custody. */
@@ -20,6 +27,12 @@ export interface Mem0Like {
20
27
  id?: string;
21
28
  }[]>;
22
29
  delete(memoryId: string): Promise<unknown>;
30
+ search?(query: string, options?: Record<string, unknown>): Promise<{
31
+ results: {
32
+ id?: string;
33
+ memory?: string;
34
+ }[];
35
+ }>;
23
36
  }
24
37
  export interface Mem0Options {
25
38
  userId: string;
@@ -43,6 +56,22 @@ export interface ZepLike {
43
56
  episode: {
44
57
  delete(uuid: string): Promise<unknown>;
45
58
  };
59
+ search?(request: {
60
+ userId?: string;
61
+ graphId?: string;
62
+ query: string;
63
+ limit?: number;
64
+ }): Promise<{
65
+ edges?: {
66
+ uuid: string;
67
+ fact: string;
68
+ episodes?: string[];
69
+ }[];
70
+ episodes?: {
71
+ uuid: string;
72
+ content: string;
73
+ }[];
74
+ }>;
46
75
  };
47
76
  }
48
77
  export type ZepOptions = {
package/dist/stores.js CHANGED
@@ -19,6 +19,15 @@ export function mem0Store(client, opts) {
19
19
  async remove(externalId) {
20
20
  await client.delete(externalId);
21
21
  },
22
+ ...(client.search
23
+ ? {
24
+ async verifyRemoved(externalId, fact) {
25
+ // Mem0's search scopes by filters, not by top-level entity parameters; the real client refuses the latter.
26
+ const { results } = await client.search(factText(fact), { filters: { user_id: opts.userId } });
27
+ return !results.some((r) => r.id === externalId || r.memory === factText(fact));
28
+ },
29
+ }
30
+ : {}),
22
31
  };
23
32
  }
24
33
  export function zepStore(client, opts) {
@@ -34,5 +43,16 @@ export function zepStore(client, opts) {
34
43
  async remove(externalId) {
35
44
  await client.graph.episode.delete(externalId);
36
45
  },
46
+ ...(client.graph.search
47
+ ? {
48
+ async verifyRemoved(externalId, fact) {
49
+ const target = opts.graphId ? { graphId: opts.graphId } : { userId: opts.userId };
50
+ const r = await client.graph.search({ ...target, query: factText(fact), limit: 20 });
51
+ const episodeHit = (r.episodes ?? []).some((e) => e.uuid === externalId);
52
+ const edgeHit = (r.edges ?? []).some((e) => (e.episodes ?? []).includes(externalId));
53
+ return !episodeHit && !edgeHit;
54
+ },
55
+ }
56
+ : {}),
37
57
  };
38
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/state",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Governed memory for AI agents: a fact ledger with provenance, valid time, rollback, and lineage, built on @agent-custody/receipts",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "node": ">=22"
34
34
  },
35
35
  "dependencies": {
36
- "@agent-custody/receipts": "0.1.7",
36
+ "@agent-custody/receipts": "0.1.9",
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
38
  "zod": "^4.5.4"
39
39
  },