@agent-custody/state 0.1.8 → 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 +12 -2
- package/dist/cli.js +37 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/pack.d.ts +49 -0
- package/dist/pack.js +148 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +39 -18
- package/dist/stores.d.ts +30 -1
- package/dist/stores.js +20 -0
- package/package.json +2 -2
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.
|
|
@@ -177,7 +184,8 @@ src/ledger.ts the fact record, the event kinds, as-of queries, supersession, r
|
|
|
177
184
|
src/storage.ts the event stores: JSONL (default, auditable) and SQLite (durable, shared), chosen by file extension
|
|
178
185
|
src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
|
|
179
186
|
src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
|
|
180
|
-
src/
|
|
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
|
|
181
189
|
src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
|
|
182
190
|
src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
|
|
183
191
|
src/evals.ts the memory-mutation harness: scenarios, scoring, report
|
|
@@ -197,6 +205,8 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
197
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.
|
|
198
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.
|
|
199
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.
|
|
200
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.
|
|
201
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.
|
|
202
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.
|
|
@@ -213,5 +223,5 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
213
223
|
|
|
214
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).
|
|
215
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).
|
|
216
|
-
3. 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).
|
|
217
227
|
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { formatReport, runAll, SCENARIOS } from "./evals.js";
|
|
12
12
|
import { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
|
|
13
13
|
import { loadScenarios, signReport, verifyReport } from "./evals-file.js";
|
|
14
|
+
import { buildPack, formatPack, signPack, verifyPack } from "./pack.js";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
17
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
@@ -52,6 +53,11 @@ const USAGE = `agent-custody-memory <command>
|
|
|
52
53
|
runs the memory-mutation scenarios on a fresh ledger; --baseline also scores a naive
|
|
53
54
|
overwrite store; --sign writes a signed report. Exits 1 if the ledger regresses.
|
|
54
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
|
|
55
61
|
export --ledger <ledger.sqlite> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line
|
|
56
62
|
blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
|
|
57
63
|
everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
|
|
@@ -64,6 +70,8 @@ async function main(argv) {
|
|
|
64
70
|
if (!values.ledger)
|
|
65
71
|
throw new Error("serve needs --ledger");
|
|
66
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.";
|
|
67
75
|
const identity = values.key ? loadPrivateKey(values.key) : undefined;
|
|
68
76
|
const retention = values.retention ? parseRetention(values.retention) : undefined;
|
|
69
77
|
const common = { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}), ...(retention ? { retention } : {}) };
|
|
@@ -73,11 +81,15 @@ async function main(argv) {
|
|
|
73
81
|
throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
|
|
74
82
|
const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, ...common, ...(token ? { tokens: [token] } : {}) });
|
|
75
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);
|
|
76
86
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
77
87
|
await running.close();
|
|
78
88
|
return 0;
|
|
79
89
|
}
|
|
80
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);
|
|
81
93
|
await serveStdio(createMemoryServer(ledger, common));
|
|
82
94
|
return 0;
|
|
83
95
|
}
|
|
@@ -146,6 +158,31 @@ async function main(argv) {
|
|
|
146
158
|
}
|
|
147
159
|
return regressed ? 1 : 0;
|
|
148
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
|
+
}
|
|
149
186
|
case "export": {
|
|
150
187
|
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, out: { type: "string" } } });
|
|
151
188
|
if (!values.ledger || !values.out)
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type { EvalReportPredicate, ReportCheck, ScenarioFile } from "./evals-fil
|
|
|
12
12
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
|
|
13
13
|
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
|
|
14
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";
|
|
15
17
|
export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
|
|
16
18
|
export type { BlastRadius, ReceiptSummary } from "./blast.ts";
|
|
17
|
-
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
|
@@ -8,3 +8,4 @@ export { EVAL_REPORT_TYPE, ScenarioFileSchema, loadScenarios, signReport, verify
|
|
|
8
8
|
export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
|
|
9
9
|
export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
|
|
10
10
|
export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
|
|
11
|
+
export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.js";
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|
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.
|
|
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.
|
|
36
|
+
"@agent-custody/receipts": "0.1.9",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
38
|
"zod": "^4.5.4"
|
|
39
39
|
},
|