@agent-custody/state 0.1.4 → 0.1.6

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
@@ -22,7 +22,7 @@ ledger.retract({ factId: a.fact.factId, actor: "user:admin", reason: "poisoned b
22
22
  ledger.asOf({ validAt: "2026-09-01T00:00:00Z", txAt: "2026-09-01T00:00:00Z" });
23
23
  ```
24
24
 
25
- Four runnable examples, all executed by the test suite. [04-evals.ts](examples/04-evals.ts) scores the ledger and a naive store on the same memory incidents. [03-memory-behind-the-gateway.ts](examples/03-memory-behind-the-gateway.ts) runs the memory server as the gateway's upstream. [01-ledger.ts](examples/01-ledger.ts) walks through a wrong write and its undo. [02-receipt-to-belief.ts](examples/02-receipt-to-belief.ts) runs the whole loop with the receipts package: a tool call gets a signed receipt, the receipt is verified, the belief taken from it is recorded citing the receipt, and later retracted. Run them with `node examples/<file>` from this directory, after `bun run build` at the repository root.
25
+ Six runnable examples, all executed by the test suite. [06-actions-on-beliefs.ts](examples/06-actions-on-beliefs.ts) puts memory and a payments API behind one gateway and finds the refund in a belief's blast radius. [05-blast-radius.ts](examples/05-blast-radius.ts) walks from a retracted belief to everything that relied on it. [04-evals.ts](examples/04-evals.ts) scores the ledger and a naive store on the same memory incidents. [03-memory-behind-the-gateway.ts](examples/03-memory-behind-the-gateway.ts) runs the memory server as the gateway's upstream. [01-ledger.ts](examples/01-ledger.ts) walks through a wrong write and its undo. [02-receipt-to-belief.ts](examples/02-receipt-to-belief.ts) runs the whole loop with the receipts package: a tool call gets a signed receipt, the receipt is verified, the belief taken from it is recorded citing the receipt, and later retracted. Run them with `node examples/<file>` from this directory, after `bun run build` at the repository root.
26
26
 
27
27
  ## The memory server
28
28
 
@@ -44,14 +44,73 @@ In the gateway's config, the memory server is the upstream, and the grant names
44
44
  | `memory.read` | the facts believed at a moment, by space, subject, predicate, valid time, transaction time | the query |
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
+ | `memory.forget` | erases the value from the ledger and every store, keeping the digest; the receipt is the certificate | `factId`, `reason` |
48
+ | `memory.hold`, `memory.release` | legal hold: while it stands the fact cannot be forgotten by request or sweep | `factId`, `reason` |
49
+ | `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
+ | `memory.get` | one fact by id in any state, for the gateway's policy lookups | `factId` |
47
51
  | `memory.history` | every event that touched a fact | `factId` |
48
52
 
49
- **Quarantine.** Every fact carries a provenance. A write that came through the gateway is `attested`: its actor is the agent named in a human-signed grant and its receipt exists. A write that arrived any other way is `claimed`, and claimed facts are quarantined: `memory.read` leaves them out unless the caller asks for `includeClaimed`, and the policy can refuse that. `memory.confirm`, accepted only through the gateway, lifts a claimed fact to attested with its own receipt and transaction time, so "was this fact still in quarantine on Tuesday" is answerable. A tool result an SDK-only agent wrote down cannot become something the rest of the fleet believes until an attested party says so. Today the server has one client, over stdio, so claimed facts reach a gateway-fronted ledger by being there before it was put under the gateway, or by a direct writer sharing the file; a shared memory server over HTTP, on the plan, is what makes mixed attested and self-reported writers a live deployment.
53
+ **Quarantine.** Every fact carries a provenance: `claimed`, `attested`, or `verified`. A write that came through the gateway is `attested`: its actor is the agent named in a human-signed grant and its receipt exists. A write that arrived any other way is `claimed`, and claimed facts are quarantined: `memory.read` leaves them out unless the caller asks for `includeClaimed`, and the policy can refuse that. `memory.confirm`, accepted only through the gateway, lifts a claimed fact to attested with its own receipt and transaction time, so "was this fact still in quarantine on Tuesday" is answerable. A tool result an SDK-only agent wrote down cannot become something the rest of the fleet believes until an attested party says so. Over stdio the server has one client; shared over HTTP, below, gateways and direct writers feed one ledger and quarantine does its job.
54
+
55
+ **Value-level quarantine.** An attested write still carries whatever value the agent chose to write. When the agent can say where a value came from, the gateway's own observation decides. A `memory.write` may name `evidence: { fact, path }`, a fact the gateway fetched itself for this call through its fact-lookup mechanism (a CRM record, say) and optionally a field in it. The memory server compares the value with the observation: equal, and the fact is written as `verified`, the third provenance level, where both the actor and the value are vouched for by something other than the agent; different, and the write is refused. A policy can require evidence for a space (`context.args has evidence`), and `memory.read` with `requireVerified` returns only verified facts. The test suite runs this with a CRM lookup behind the same gateway as the memory server.
56
+
57
+ **Policy over the fact being changed.** The gateway can look up the fact a write supersedes or a retraction targets before deciding, through its fact-lookup mechanism and the `memory.get` tool, and the policy then sees that fact's space, actor, and provenance as observed facts. This is the second half of trust tiers: a self-reported note in the org space can be superseded by anyone the grant allows, while an attested org fact cannot be displaced or retracted except by whoever the policy names. In the gateway config:
58
+
59
+ ```json
60
+ "facts": [
61
+ { "name": "target", "tool": "memory.get", "args": { "factId": "$args.supersedes" }, "forTools": ["memory.write"], "optional": true },
62
+ { "name": "target", "tool": "memory.get", "args": { "factId": "$args.factId" }, "forTools": ["memory.retract"] }
63
+ ]
64
+ ```
65
+
66
+ ```cedar
67
+ forbid(principal, action in [Action::"memory.write", Action::"memory.retract"], resource)
68
+ when { context.facts has target && context.facts.target.space == "org" && context.facts.target.provenance == "attested" };
69
+ ```
70
+
71
+ The lookup for writes is optional, so a write that supersedes nothing needs no lookup; the one for retractions is required. The denial receipt records the fact the policy saw, observed by the gateway. The test suite runs exactly this configuration.
72
+
73
+ **Attested executions.** Started with `--key memory.key`, the server signs every result for the receipt the gateway is issuing. A verifier given `memory.pub` then reports the execution of each memory call as attested by the memory server, not only observed by the gateway; the test suite verifies a write this way.
74
+
75
+ **Shared over HTTP.** `agent-custody-memory serve --ledger ./ledger.jsonl --http --port 8790 --token-env MEMORY_TOKEN` serves the same tools over Streamable HTTP, so several gateways, one per agent host, and, with `--allow-direct`, SDK-only agents share one ledger. That is the deployment quarantine was built for: writes arriving through a gateway are attested, writes arriving directly are claimed and hidden until a gateway confirms them, and the ledger tells them apart. A gateway reaches it with `"upstream": { "url": "http://127.0.0.1:8790/mcp", "tokenEnv": "MEMORY_TOKEN" }` in its config. Bind wider than loopback only behind the token. The test suite runs exactly this: one gateway writer, one direct writer, one ledger.
50
76
 
51
77
  Trust tiers are Cedar policies over the space and, through `includeClaimed`, over quarantine: `permit(principal, action == Action::"memory.write", resource) when { context.args.space == "team:support" };` lets this agent write team memory and nothing else. A read's receipt carries, as `observed`, the exact facts returned, so the ids the agent relied on are already on the record.
52
78
 
53
79
  `--allow-direct` lets the server take calls without a gateway; then `source.receiptId` is null and `actor` is whatever the caller said, recorded as such. [examples/03-memory-behind-the-gateway.ts](examples/03-memory-behind-the-gateway.ts) runs the whole loop, including a denied write and a retraction that cites its own receipt.
54
80
 
81
+ ## Certified forget
82
+
83
+ A deletion demand is different from a correction. Retract keeps the record; forget erases the value. `memory.forget` removes the fact's value from the ledger file itself, replacing it with the value's digest so the ledger can still prove what it held without holding it, stops believing the fact, and removes it from every store behind the server. The result says exactly what happened: erased from the ledger, removed from which stores, still held by which, with the digest, the actor, and the reason.
84
+
85
+ **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. `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
+ 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
+
89
+ What forget does not reach, and the docs will not pretend otherwise: receipts. The receipt that recorded the original write carries the value in its request arguments, and the receipts of reads carry it in their results; they are signed and in a Merkle log, so they cannot be edited. Erasure from the receipt log is a retention policy on the log, and selective redaction of receipts is on the receipts roadmap.
90
+
91
+ ## Blast radius
92
+
93
+ When a belief turns out wrong, the next question is what relied on it. Two records answer it together. Every gateway receipt carries `consumed`: the fact ids the agent had been shown, through the gateway, before that call, which the memory server declares on each read. Every fact in the ledger carries the receipt that wrote it. Walking forward from a fact through those two links gives every later call and every belief written in those calls, transitively, and whether the root was retracted and which derived beliefs are still believed.
94
+
95
+ ```bash
96
+ agent-custody-memory blast --ledger ./ledger.jsonl --receipts ./receipts --fact <factId>
97
+ ```
98
+
99
+ ```
100
+ fact 3f2a…: acct:42 plan = "enterprise" (space team:support, by support-agent, attested)
101
+ retracted at 2026-09-07T10:12:04.118Z by support-agent: CRM sync bug: account is on the free plan
102
+ 3 call(s) made after the agent was shown it:
103
+ 2026-09-07T10:12:03.902Z memory.write executed receipt 7c1e…
104
+ ...
105
+ 2 belief(s) written in those calls, 2 still believed:
106
+ acct:42 discount = "20%" fact 9b04… STILL BELIEVED
107
+ acct:42 support_tier = "priority" fact e77d… STILL BELIEVED
108
+ ```
109
+
110
+ With the gateway fronting several upstreams, memory and the tools the agent acts with, the radius reaches the actions too: a refund issued after the agent read a belief carries that belief's id and is listed. [examples/06-actions-on-beliefs.ts](examples/06-actions-on-beliefs.ts) shows it.
111
+
112
+ It is an upper bound by design: a call made after the agent had seen the fact is in the radius whether or not the agent used it, because no receipt can prove what a model attended to. What it never misses is the thing that matters, a downstream action or belief that did depend on the fact. [examples/05-blast-radius.ts](examples/05-blast-radius.ts) runs the whole loop.
113
+
55
114
  ## Write-through to the stores you already use
56
115
 
57
116
  The ledger is not a retrieval store, and it does not try to be. `src/stores.ts` puts it under the ones teams already run: a fact written through the memory server also lands in every configured store, with its custody metadata (fact id, space, actor, provenance, receipt id), the store's own id is recorded on the fact, and a retraction reaches the store by that id. Certified forget will be built on this: a deletion is only real once it has reached the stores that serve recall.
@@ -102,7 +161,9 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
102
161
  ```
103
162
  src/ledger.ts the fact record, the two event kinds, as-of queries, supersession, retraction, JSONL persistence
104
163
  src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
105
- src/cli.ts agent-custody-memory serve
164
+ 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), sweep, blast
166
+ src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
106
167
  src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
107
168
  src/evals.ts the memory-mutation harness: scenarios, scoring, report
108
169
  src/evals-ledger.ts the ledger and a naive overwrite store behind the harness interface
@@ -118,13 +179,18 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
118
179
 
119
180
  - Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
120
181
  - 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
+ - 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.
183
+ - 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.
184
+ - 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.
185
+ - The memory server over HTTP: one ledger shared by several gateways and direct writers, bearer-token auth, quarantine live.
186
+ - Certified forget: `memory.forget` erases a value from the ledger file keeping its digest, stops believing it, removes it from every store, and reports exactly what happened; the gateway's receipt of that call is the certificate.
187
+ - 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.
188
+ - 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.
121
189
  - 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.
122
190
  - 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.
123
191
  - 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.
124
192
 
125
193
  **Next, in the order it pays off**
126
194
 
127
- 1. A consumed-facts field on receipts: the gateway records which fact ids a read returned, so later receipts in the session show what the agent relied on.
128
- 2. Blast radius: given a fact id, every downstream receipt and derived fact that cited it, and the retraction that undoes the belief.
129
- 3. Trust tiers, the rest: Cedar policy over provenance so a claimed write cannot supersede an attested org-space fact, and quarantine of values that came from untrusted tool output even when the actor is attested.
130
- 4. Signed forget statements: a retention or deletion request produces a verifiable record of which facts were removed from the ledger and from every store behind it.
195
+ 1. A retention schedule: run sweeps on a timer from the CLI or a hosted plane, with the receipts of each sweep kept as the record.
196
+
@@ -0,0 +1,25 @@
1
+ import type { Fact, Ledger, RetractEvent } from "./ledger.ts";
2
+ /** The parts of a receipt this query needs. Decoded from a bundle's statement; nothing here is verified, so verify first. */
3
+ export interface ReceiptSummary {
4
+ receiptId: string;
5
+ timestamp: string;
6
+ tool: string;
7
+ status: string;
8
+ /** fact ids the agent had been shown before this call, from the receipt's consumed field */
9
+ consumed: string[];
10
+ }
11
+ /** Reads every bundle in a receipts directory. Order is by timestamp. */
12
+ export declare function loadReceipts(dir: string): ReceiptSummary[];
13
+ export interface BlastRadius {
14
+ fact: Fact | null;
15
+ /** every receipt for a call made after the agent had been shown this fact or one derived from it */
16
+ receipts: ReceiptSummary[];
17
+ /** every fact written in one of those calls, transitively */
18
+ derivedFacts: Fact[];
19
+ /** the retraction of the root fact, when it has been undone */
20
+ retraction: RetractEvent | null;
21
+ /** derived facts that are still believed; the ones a cleanup has to decide about */
22
+ stillBelieved: Fact[];
23
+ }
24
+ export declare function blastRadius(ledger: Ledger, receipts: ReceiptSummary[], factId: string): BlastRadius;
25
+ export declare function formatBlastRadius(b: BlastRadius, factId: string): string;
package/dist/blast.js ADDED
@@ -0,0 +1,62 @@
1
+ // Blast radius: given a fact, everything that relied on it. The receipts say which facts the agent had been shown
2
+ // before each call; the ledger says which facts were written in which call. Walking both, forward, gives every
3
+ // downstream action and every derived belief, transitively, and the retraction that undoes the belief if there is one.
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ /** Reads every bundle in a receipts directory. Order is by timestamp. */
7
+ export function loadReceipts(dir) {
8
+ const out = [];
9
+ for (const f of readdirSync(dir)) {
10
+ if (!f.endsWith(".json"))
11
+ continue;
12
+ const bundle = JSON.parse(readFileSync(join(dir, f), "utf8"));
13
+ if (!bundle.envelope?.payload)
14
+ continue;
15
+ const st = JSON.parse(Buffer.from(bundle.envelope.payload, "base64").toString());
16
+ const p = st.predicate;
17
+ if (!p?.receiptId)
18
+ continue;
19
+ out.push({ receiptId: p.receiptId, timestamp: p.timestamp, tool: p.tool?.name ?? "?", status: p.execution?.status ?? "?", consumed: Array.isArray(p.consumed?.factIds) ? p.consumed.factIds : [] });
20
+ }
21
+ return out.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
22
+ }
23
+ export function blastRadius(ledger, receipts, factId) {
24
+ const all = ledger.facts();
25
+ const byId = new Map(all.map((f) => [f.factId, f]));
26
+ const believed = new Set(ledger.asOf().map((f) => f.factId));
27
+ const seen = new Set([factId]);
28
+ const hit = new Map();
29
+ const derived = new Map();
30
+ let grew = true;
31
+ while (grew) {
32
+ grew = false;
33
+ for (const r of receipts) {
34
+ if (hit.has(r.receiptId) || !r.consumed.some((id) => seen.has(id)))
35
+ continue;
36
+ hit.set(r.receiptId, r);
37
+ grew = true;
38
+ }
39
+ for (const f of all) {
40
+ if (f.factId === factId || derived.has(f.factId) || !f.source.receiptId || !hit.has(f.source.receiptId))
41
+ continue;
42
+ derived.set(f.factId, f);
43
+ seen.add(f.factId);
44
+ grew = true;
45
+ }
46
+ }
47
+ const retraction = ledger.history(factId).find((e) => e.kind === "retract") ?? null;
48
+ const derivedFacts = [...derived.values()];
49
+ return { fact: byId.get(factId) ?? null, receipts: [...hit.values()].sort((a, b) => a.timestamp.localeCompare(b.timestamp)), derivedFacts, retraction, stillBelieved: derivedFacts.filter((f) => believed.has(f.factId)) };
50
+ }
51
+ export function formatBlastRadius(b, factId) {
52
+ const lines = [];
53
+ lines.push(b.fact ? `fact ${factId}: ${b.fact.subject} ${b.fact.predicate} = ${JSON.stringify(b.fact.value)} (space ${b.fact.space}, by ${b.fact.actor}, ${b.fact.provenance})` : `fact ${factId}: not in this ledger`);
54
+ lines.push(b.retraction ? `retracted at ${b.retraction.txTime} by ${b.retraction.actor}: ${b.retraction.reason}` : "not retracted");
55
+ lines.push(`${b.receipts.length} call(s) made after the agent was shown it:`);
56
+ for (const r of b.receipts)
57
+ lines.push(` ${r.timestamp} ${r.tool.padEnd(18)} ${r.status.padEnd(9)} receipt ${r.receiptId.slice(0, 8)}`);
58
+ lines.push(`${b.derivedFacts.length} belief(s) written in those calls, ${b.stillBelieved.length} still believed:`);
59
+ for (const f of b.derivedFacts)
60
+ lines.push(` ${f.subject} ${f.predicate} = ${JSON.stringify(f.value)} fact ${f.factId.slice(0, 8)} ${b.stillBelieved.includes(f) ? "STILL BELIEVED" : "no longer believed"}`);
61
+ return lines.join("\n");
62
+ }
package/dist/cli.js CHANGED
@@ -2,23 +2,64 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import { Ledger } from "./ledger.js";
4
4
  import { createMemoryServer, serveStdio } from "./server.js";
5
+ import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
6
+ import { serveMemoryHttp } from "./http.js";
7
+ import { loadPrivateKey } from "@agent-custody/receipts";
5
8
  const USAGE = `agent-custody-memory <command>
6
9
 
7
- serve --ledger <ledger.jsonl> [--allow-direct] the memory server over stdio; run it as the receipts gateway's upstream.
10
+ serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>]
11
+ the memory server over stdio; run it as the receipts gateway's upstream.
12
+ --key signs every result for its receipt, so executions verify as attested by this server.
8
13
  By default it refuses calls that did not come through the gateway.
14
+ serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
15
+ the same server shared over HTTP: several gateways, one ledger
16
+ sweep --ledger <ledger.jsonl> --before <ISO instant> [--space <space>] --reason <text> [--actor <id>]
17
+ retention on the ledger file alone: forgets what was learned before the instant, skipping held facts.
18
+ Through the gateway, memory.sweep does the same and reaches the stores.
19
+ blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
20
+ everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
9
21
  `;
10
22
  async function main(argv) {
11
23
  const [cmd, ...rest] = argv;
12
24
  switch (cmd) {
13
25
  case "serve": {
14
- const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, "allow-direct": { type: "boolean", default: false } } });
26
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, "allow-direct": { type: "boolean", default: false }, http: { type: "boolean", default: false }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, key: { type: "string" } } });
15
27
  if (!values.ledger)
16
28
  throw new Error("serve needs --ledger");
17
29
  const ledger = new Ledger(values.ledger);
30
+ const identity = values.key ? loadPrivateKey(values.key) : undefined;
31
+ if (values.http) {
32
+ const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
33
+ if (values["token-env"] && !token)
34
+ throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
35
+ const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, requireGateway: !values["allow-direct"], ...(token ? { tokens: [token] } : {}), ...(identity ? { identity } : {}) });
36
+ 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"}`);
37
+ await new Promise((resolve) => process.once("SIGINT", resolve));
38
+ await running.close();
39
+ return 0;
40
+ }
18
41
  console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.size} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
19
- await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"] }));
42
+ await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}) }));
20
43
  return 0;
21
44
  }
45
+ case "sweep": {
46
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, before: { type: "string" }, space: { type: "string" }, reason: { type: "string" }, actor: { type: "string", default: "cli" } } });
47
+ if (!values.ledger || !values.before || !values.reason)
48
+ throw new Error("sweep needs --ledger, --before, and --reason");
49
+ const r = new Ledger(values.ledger).sweep({ before: new Date(values.before).toISOString(), ...(values.space ? { space: values.space } : {}), actor: values.actor, reason: values.reason });
50
+ console.log(`forgot ${r.forgotten.length} fact(s); ${r.held.length} on hold, kept`);
51
+ for (const f of r.forgotten)
52
+ console.log(` ${f.factId} digest ${f.valueDigest.slice(0, 12)}`);
53
+ return 0;
54
+ }
55
+ case "blast": {
56
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, json: { type: "boolean", default: false } } });
57
+ if (!values.ledger || !values.receipts || !values.fact)
58
+ throw new Error("blast needs --ledger, --receipts, and --fact");
59
+ const b = blastRadius(new Ledger(values.ledger), loadReceipts(values.receipts), values.fact);
60
+ console.log(values.json ? JSON.stringify(b, null, 2) : formatBlastRadius(b, values.fact));
61
+ return b.fact ? 0 : 1;
62
+ }
22
63
  default:
23
64
  console.error(USAGE);
24
65
  return cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 2;
package/dist/http.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { Ledger } from "./ledger.ts";
3
+ import { type MemoryServerOptions } from "./server.ts";
4
+ export interface MemoryHttpOptions extends MemoryServerOptions {
5
+ port: number;
6
+ host?: string;
7
+ /** bearer tokens accepted; when empty, anyone who can reach the port may call */
8
+ tokens?: string[];
9
+ }
10
+ export interface RunningMemoryServer {
11
+ url: string;
12
+ close(): Promise<void>;
13
+ }
14
+ export declare function memoryHttpHandler(ledger: Ledger, opts: MemoryHttpOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
15
+ /** Starts the memory server over HTTP. Port 0 picks a free port. Host defaults to loopback; bind wider only behind auth. */
16
+ export declare function serveMemoryHttp(ledger: Ledger, opts: MemoryHttpOptions): Promise<RunningMemoryServer>;
package/dist/http.js ADDED
@@ -0,0 +1,46 @@
1
+ // The memory server over HTTP: one ledger shared by several gateways and, if allowed, direct writers. This is what
2
+ // makes quarantine a live deployment: attested writes arrive through gateways, self-reported ones directly, and the
3
+ // ledger tells them apart. Stateless Streamable HTTP, one MCP server instance per request over the shared ledger.
4
+ import { timingSafeEqual } from "node:crypto";
5
+ import { createServer } from "node:http";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { createMemoryServer } from "./server.js";
8
+ export function memoryHttpHandler(ledger, opts) {
9
+ const tokens = opts.tokens ?? [];
10
+ const authorized = (req) => {
11
+ if (tokens.length === 0)
12
+ return true;
13
+ const h = req.headers.authorization ?? "";
14
+ const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
15
+ return tokens.some((t) => Buffer.from(t).length === given.length && timingSafeEqual(Buffer.from(t), given));
16
+ };
17
+ return async (req, res) => {
18
+ if (!authorized(req)) {
19
+ res.writeHead(401, { "content-type": "application/json" });
20
+ res.end(JSON.stringify({ error: "unauthorized" }));
21
+ return;
22
+ }
23
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
24
+ const server = createMemoryServer(ledger, opts);
25
+ res.on("close", () => {
26
+ void transport.close();
27
+ void server.close();
28
+ });
29
+ await server.connect(transport);
30
+ await transport.handleRequest(req, res);
31
+ };
32
+ }
33
+ /** Starts the memory server over HTTP. Port 0 picks a free port. Host defaults to loopback; bind wider only behind auth. */
34
+ export function serveMemoryHttp(ledger, opts) {
35
+ const host = opts.host ?? "127.0.0.1";
36
+ const handler = memoryHttpHandler(ledger, opts);
37
+ const server = createServer((req, res) => {
38
+ void handler(req, res);
39
+ });
40
+ return new Promise((resolve) => {
41
+ server.listen(opts.port, host, () => {
42
+ const { port } = server.address();
43
+ resolve({ url: `http://${host}:${port}/mcp`, close: () => new Promise((r) => server.close(() => r())) });
44
+ });
45
+ });
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export { Ledger } from "./ledger.ts";
2
- export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, LedgerEvent, RetractEvent, RetractInput, Source } from "./ledger.ts";
3
- export { AGENT_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.ts";
2
+ export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, ForgetEvent, ForgetInput, HoldEvent, HoldInput, LedgerEvent, SweepInput, RetractEvent, RetractInput, Source } from "./ledger.ts";
3
+ export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.ts";
4
4
  export type { MemoryServerOptions } from "./server.ts";
5
5
  export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.ts";
6
6
  export type { MemoryUnderTest, Op, Report, Scenario, ScenarioScore } from "./evals.ts";
7
7
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.ts";
8
8
  export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
9
+ export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
10
+ export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
11
+ export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
12
+ export type { BlastRadius, ReceiptSummary } from "./blast.ts";
9
13
  export type { Mem0Like, Mem0Options, Store, ZepLike, ZepOptions } from "./stores.ts";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Public surface of @agent-custody/state.
2
2
  export { Ledger } from "./ledger.js";
3
- export { AGENT_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.js";
3
+ export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, serveStdio } from "./server.js";
4
4
  export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
5
5
  export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
6
6
  export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
7
+ export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
8
+ export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
package/dist/ledger.d.ts CHANGED
@@ -3,11 +3,14 @@ export interface Source {
3
3
  receiptId: string | null;
4
4
  }
5
5
  /**
6
- * How far the write can be trusted. attested: it came through the receipts gateway, so the actor is the agent named in
7
- * a human-signed grant and the receipt exists. claimed: it came from somewhere that only says who it is. A claimed
8
- * fact is quarantined: the memory server does not return it by default until an attested party confirms it.
6
+ * How far the write can be trusted.
7
+ * - claimed: it came from somewhere that only says who it is. Quarantined: not returned by default until confirmed.
8
+ * - attested: it came through the receipts gateway, so the actor is the agent named in a human-signed grant and the
9
+ * receipt exists. The value is still what the agent said.
10
+ * - verified: attested, and the value equals what the gateway itself fetched from the source system for this call.
11
+ * The actor and the value are both vouched for by something other than the agent.
9
12
  */
10
- export type FactProvenance = "attested" | "claimed";
13
+ export type FactProvenance = "claimed" | "attested" | "verified";
11
14
  export interface Fact {
12
15
  factId: string;
13
16
  subject: string;
@@ -19,6 +22,11 @@ export interface Fact {
19
22
  actor: string;
20
23
  source: Source;
21
24
  provenance: FactProvenance;
25
+ /** present once the value has been erased: the value field is null and this is the digest of what it was */
26
+ forgotten?: {
27
+ valueDigest: string;
28
+ at: string;
29
+ };
22
30
  /** ids of this fact in the retrieval stores it was written through to, by store name; absent when there are none */
23
31
  external?: Record<string, string>;
24
32
  /** ISO timestamps. validTo is null while the fact is believed to still hold. */
@@ -53,7 +61,32 @@ export interface ConfirmEvent {
53
61
  actor: string;
54
62
  source: Source;
55
63
  }
56
- export type LedgerEvent = AssertEvent | RetractEvent | ConfirmEvent;
64
+ /**
65
+ * A forget is erasure, not correction. The fact's value is removed from the ledger file itself and replaced by its
66
+ * digest, so the ledger can still prove which value it held without holding it. The fact stops being believed.
67
+ */
68
+ export interface ForgetEvent {
69
+ eventId: string;
70
+ kind: "forget";
71
+ txTime: string;
72
+ factId: string;
73
+ actor: string;
74
+ reason: string;
75
+ source: Source;
76
+ /** sha256 of the canonical JSON of the erased value */
77
+ valueDigest: string;
78
+ }
79
+ /** A legal hold: while it stands, the fact cannot be forgotten, by request or by retention sweep. Release lifts it. */
80
+ export interface HoldEvent {
81
+ eventId: string;
82
+ kind: "hold" | "release";
83
+ txTime: string;
84
+ factId: string;
85
+ actor: string;
86
+ reason: string;
87
+ source: Source;
88
+ }
89
+ export type LedgerEvent = AssertEvent | RetractEvent | ConfirmEvent | ForgetEvent | HoldEvent;
57
90
  export interface AssertInput {
58
91
  subject: string;
59
92
  predicate: string;
@@ -73,6 +106,26 @@ export interface ConfirmInput {
73
106
  actor: string;
74
107
  source?: Source;
75
108
  }
109
+ export interface ForgetInput {
110
+ factId: string;
111
+ actor: string;
112
+ reason: string;
113
+ source?: Source;
114
+ }
115
+ export interface HoldInput {
116
+ factId: string;
117
+ actor: string;
118
+ reason: string;
119
+ source?: Source;
120
+ }
121
+ export interface SweepInput {
122
+ /** every fact the ledger learned of before this instant is forgotten, unless held or already forgotten */
123
+ before: string;
124
+ space?: string;
125
+ actor: string;
126
+ reason: string;
127
+ source?: Source;
128
+ }
76
129
  export interface RetractInput {
77
130
  factId: string;
78
131
  actor: string;
@@ -87,8 +140,8 @@ export interface AsOf {
87
140
  space?: string;
88
141
  subject?: string;
89
142
  predicate?: string;
90
- /** "attested" returns only facts that were attested at txAt; default "all" */
91
- include?: "attested" | "all";
143
+ /** the least provenance to return: "attested" leaves out quarantined facts, "verified" leaves out everything the agent only asserted; default "all" */
144
+ include?: "attested" | "verified" | "all";
92
145
  }
93
146
  export declare class Ledger {
94
147
  private readonly events;
@@ -103,8 +156,25 @@ export declare class Ledger {
103
156
  assert(input: AssertInput): AssertEvent;
104
157
  retract(input: RetractInput): RetractEvent;
105
158
  confirm(input: ConfirmInput): ConfirmEvent;
159
+ /**
160
+ * Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
161
+ * place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
162
+ * stays: who wrote it, when, from which receipt, and now who erased it and why.
163
+ */
164
+ /** Whether a legal hold currently stands on the fact. */
165
+ held(factId: string): boolean;
166
+ hold(input: HoldInput): HoldEvent;
167
+ release(input: HoldInput): HoldEvent;
168
+ /** Retention: forgets every fact the ledger learned of before the cutoff, in one space or all, skipping held and already-forgotten facts. Returns what it forgot and what it skipped. */
169
+ sweep(input: SweepInput): {
170
+ forgotten: ForgetEvent[];
171
+ held: string[];
172
+ };
173
+ forget(input: ForgetInput): ForgetEvent;
106
174
  /** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
107
175
  asOf(q?: AsOf): Fact[];
176
+ /** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
177
+ facts(): Fact[];
108
178
  /** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
109
179
  history(factId: string): LedgerEvent[];
110
180
  private confirmedAt;
package/dist/ledger.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // Bitemporal. Valid time is when a fact was true in the world; transaction time is when the ledger learned of it.
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
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
5
+ import { createHash } from "node:crypto";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
7
  import { dirname } from "node:path";
8
+ const RANK = { claimed: 0, attested: 1, verified: 2 };
7
9
  export class Ledger {
8
10
  events = [];
9
11
  file;
@@ -89,18 +91,87 @@ export class Ledger {
89
91
  throw new Error(`cannot confirm unknown fact ${input.factId}`);
90
92
  if (this.retractedAt(input.factId))
91
93
  throw new Error(`fact ${input.factId} is retracted`);
92
- if (prior.fact.provenance === "attested" || this.confirmedAt(input.factId))
94
+ if (prior.fact.provenance !== "claimed" || this.confirmedAt(input.factId))
93
95
  throw new Error(`fact ${input.factId} is already attested`);
94
96
  const event = { eventId: randomUUID(), kind: "confirm", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, source: input.source ?? { receiptId: null } };
95
97
  this.append(event);
96
98
  return event;
97
99
  }
100
+ /**
101
+ * Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
102
+ * place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
103
+ * stays: who wrote it, when, from which receipt, and now who erased it and why.
104
+ */
105
+ /** Whether a legal hold currently stands on the fact. */
106
+ held(factId) {
107
+ let held = false;
108
+ for (const e of this.events)
109
+ if ((e.kind === "hold" || e.kind === "release") && e.factId === factId)
110
+ held = e.kind === "hold";
111
+ return held;
112
+ }
113
+ hold(input) {
114
+ const prior = this.factById(input.factId);
115
+ if (!prior)
116
+ throw new Error(`cannot hold unknown fact ${input.factId}`);
117
+ if (prior.fact.forgotten)
118
+ throw new Error(`fact ${input.factId} is already forgotten`);
119
+ if (this.held(input.factId))
120
+ throw new Error(`fact ${input.factId} is already on hold`);
121
+ const event = { eventId: randomUUID(), kind: "hold", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null } };
122
+ this.append(event);
123
+ return event;
124
+ }
125
+ release(input) {
126
+ if (!this.held(input.factId))
127
+ throw new Error(`fact ${input.factId} is not on hold`);
128
+ const event = { eventId: randomUUID(), kind: "release", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null } };
129
+ this.append(event);
130
+ return event;
131
+ }
132
+ /** Retention: forgets every fact the ledger learned of before the cutoff, in one space or all, skipping held and already-forgotten facts. Returns what it forgot and what it skipped. */
133
+ sweep(input) {
134
+ const forgotten = [];
135
+ const held = [];
136
+ const targets = this.events.filter((e) => e.kind === "assert" && e.txTime < input.before && (input.space === undefined || e.fact.space === input.space) && !e.fact.forgotten);
137
+ for (const e of targets) {
138
+ if (this.held(e.fact.factId)) {
139
+ held.push(e.fact.factId);
140
+ continue;
141
+ }
142
+ forgotten.push(this.forget({ factId: e.fact.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}) }));
143
+ }
144
+ return { forgotten, held };
145
+ }
146
+ forget(input) {
147
+ const prior = this.factById(input.factId);
148
+ if (!prior)
149
+ throw new Error(`cannot forget unknown fact ${input.factId}`);
150
+ if (prior.fact.forgotten)
151
+ throw new Error(`fact ${input.factId} is already forgotten`);
152
+ if (this.held(input.factId))
153
+ throw new Error(`fact ${input.factId} is on legal hold; release it first`);
154
+ const txTime = this.now().toISOString();
155
+ const valueDigest = createHash("sha256").update(canonical(prior.fact.value)).digest("hex");
156
+ for (const e of this.events) {
157
+ if (e.kind === "assert" && e.fact.factId === input.factId) {
158
+ e.fact.value = null;
159
+ e.fact.forgotten = { valueDigest, at: txTime };
160
+ }
161
+ }
162
+ const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest };
163
+ this.events.push(event);
164
+ const tmp = `${this.file}.tmp`;
165
+ writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
166
+ renameSync(tmp, this.file);
167
+ return event;
168
+ }
98
169
  /** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
99
170
  asOf(q = {}) {
100
171
  const validAt = q.validAt ?? this.now().toISOString();
101
172
  const txAt = q.txAt ?? this.now().toISOString();
102
173
  const known = this.events.filter((e) => e.txTime <= txAt);
103
- const retracted = new Set(known.filter((e) => e.kind === "retract").map((e) => e.factId));
174
+ const retracted = new Set(known.filter((e) => e.kind === "retract" || e.kind === "forget").map((e) => e.factId));
104
175
  const confirmed = new Set(known.filter((e) => e.kind === "confirm").map((e) => e.factId));
105
176
  const facts = new Map();
106
177
  for (const e of known) {
@@ -117,7 +188,19 @@ export class Ledger {
117
188
  (q.space === undefined || f.space === q.space) &&
118
189
  (q.subject === undefined || f.subject === q.subject) &&
119
190
  (q.predicate === undefined || f.predicate === q.predicate) &&
120
- (q.include !== "attested" || f.provenance === "attested"));
191
+ (q.include === undefined || q.include === "all" || RANK[f.provenance] >= RANK[q.include]));
192
+ }
193
+ /** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
194
+ facts() {
195
+ const out = new Map();
196
+ for (const e of this.events) {
197
+ if (e.kind !== "assert")
198
+ continue;
199
+ out.set(e.fact.factId, { ...e.fact });
200
+ if (e.supersedes && out.has(e.supersedes) && !this.retractedAt(e.fact.factId))
201
+ out.get(e.supersedes).validTo = e.fact.validFrom;
202
+ }
203
+ return [...out.values()];
121
204
  }
122
205
  /** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
123
206
  history(factId) {
@@ -137,10 +220,28 @@ export class Ledger {
137
220
  return found;
138
221
  }
139
222
  retractedAt(factId) {
140
- return this.events.some((e) => e.kind === "retract" && e.factId === factId);
223
+ return this.events.some((e) => (e.kind === "retract" || e.kind === "forget") && e.factId === factId);
141
224
  }
142
225
  append(event) {
143
226
  appendFileSync(this.file, JSON.stringify(event) + "\n");
144
227
  this.events.push(event);
145
228
  }
146
229
  }
230
+ /** Canonical JSON: sorted keys, no whitespace, undefined dropped. The same encoding the receipts package uses. */
231
+ function canonical(value) {
232
+ const sort = (v) => {
233
+ if (Array.isArray(v))
234
+ return v.map(sort);
235
+ if (v && typeof v === "object") {
236
+ const o = {};
237
+ for (const k of Object.keys(v).sort()) {
238
+ const x = v[k];
239
+ if (x !== undefined)
240
+ o[k] = sort(x);
241
+ }
242
+ return o;
243
+ }
244
+ return v;
245
+ };
246
+ return JSON.stringify(sort(value));
247
+ }
package/dist/server.d.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { type Tool } from "@modelcontextprotocol/sdk/types.js";
3
+ import { type KeyPair } from "@agent-custody/receipts";
3
4
  import type { Ledger } from "./ledger.ts";
4
5
  import type { Store } from "./stores.ts";
5
6
  export declare const SERVER_VERSION = "0.1.0";
6
7
  /** The same keys the receipts gateway sets on the upstream call. Duplicated here so this package needs no runtime import from receipts. */
7
8
  export declare const RECEIPT_META_KEY = "agent-custody/receipt";
8
9
  export declare const AGENT_META_KEY = "agent-custody/agent";
10
+ /** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
11
+ export declare const FACTS_META_KEY = "agent-custody/facts";
12
+ /** Set by the gateway on the forwarded call: the values it fetched itself for this call, by fact name. */
13
+ export declare const OBSERVED_META_KEY = "agent-custody/observed";
9
14
  export declare const TOOLS: Tool[];
10
15
  export interface MemoryServerOptions {
11
16
  /** Refuse calls that did not come through the gateway, i.e. carry no receipt id. On by default when served from the CLI. */
12
17
  requireGateway?: boolean;
13
18
  /** Retrieval stores every write goes through to and every retraction reaches. A store that refuses a write fails the write; nothing is recorded. */
14
19
  stores?: Store[];
20
+ /** With a key, every result to a gateway call is signed for that receipt, so a verifier holding the public key sees the execution as attested by this server. */
21
+ identity?: KeyPair;
15
22
  }
16
23
  export declare function createMemoryServer(ledger: Ledger, opts?: MemoryServerOptions): Server;
17
24
  /** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
package/dist/server.js CHANGED
@@ -7,10 +7,15 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { z } from "zod";
10
+ import { signResult } from "@agent-custody/receipts";
10
11
  export const SERVER_VERSION = "0.1.0";
11
12
  /** The same keys the receipts gateway sets on the upstream call. Duplicated here so this package needs no runtime import from receipts. */
12
13
  export const RECEIPT_META_KEY = "agent-custody/receipt";
13
14
  export const AGENT_META_KEY = "agent-custody/agent";
15
+ /** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
16
+ export const FACTS_META_KEY = "agent-custody/facts";
17
+ /** Set by the gateway on the forwarded call: the values it fetched itself for this call, by fact name. */
18
+ export const OBSERVED_META_KEY = "agent-custody/observed";
14
19
  const iso = z.string().datetime({ offset: true });
15
20
  const Write = z.object({
16
21
  subject: z.string().min(1),
@@ -22,22 +27,28 @@ const Write = z.object({
22
27
  validFrom: iso.optional(),
23
28
  confidence: z.number().min(0).max(1).optional(),
24
29
  supersedes: z.string().min(1).optional(),
30
+ /** names a fact the gateway fetched for this call, and optionally a dot path into it, that the value must equal; the write is then verified, or refused */
31
+ evidence: z.object({ fact: z.string().min(1), path: z.string().optional() }).optional(),
25
32
  });
26
- const Read = z.object({ subject: z.string().min(1).optional(), predicate: z.string().min(1).optional(), space: z.string().min(1).optional(), validAt: iso.optional(), txAt: iso.optional(), includeClaimed: z.boolean().optional() });
33
+ const Read = z.object({ subject: z.string().min(1).optional(), predicate: z.string().min(1).optional(), space: z.string().min(1).optional(), validAt: iso.optional(), txAt: iso.optional(), includeClaimed: z.boolean().optional(), requireVerified: z.boolean().optional() });
27
34
  const Confirm = z.object({ factId: z.string().min(1) });
28
35
  const Retract = z.object({ factId: z.string().min(1), reason: z.string().min(1), actor: z.string().min(1).optional() });
29
36
  const History = z.object({ factId: z.string().min(1) });
37
+ const Get = z.object({ factId: z.string().min(1) });
38
+ const Forget = z.object({ factId: z.string().min(1), reason: z.string().min(1) });
39
+ const Hold = z.object({ factId: z.string().min(1), reason: z.string().min(1) });
40
+ const Sweep = z.object({ before: iso, space: z.string().min(1).optional(), reason: z.string().min(1) });
30
41
  const str = { type: "string" };
31
42
  export const TOOLS = [
32
43
  {
33
44
  name: "memory.write",
34
- description: "Record a belief: subject, predicate, value, in a space. Optionally supersede an earlier fact. Returns the new fact with its id, transaction time, actor, and source receipt.",
35
- inputSchema: { type: "object", properties: { subject: str, predicate: str, value: {}, space: str, actor: str, validFrom: str, confidence: { type: "number" }, supersedes: str }, required: ["subject", "predicate", "value", "space"] },
45
+ description: "Record a belief: subject, predicate, value, in a space. Optionally supersede an earlier fact. With evidence naming a fact the gateway fetched for this call, the value must equal it (or the field at path) and the write is verified; otherwise it is refused. Returns the new fact with its id, transaction time, actor, provenance, and source receipt.",
46
+ inputSchema: { type: "object", properties: { subject: str, predicate: str, value: {}, space: str, actor: str, validFrom: str, confidence: { type: "number" }, supersedes: str, evidence: { type: "object", properties: { fact: str, path: str }, required: ["fact"] } }, required: ["subject", "predicate", "value", "space"] },
36
47
  },
37
48
  {
38
49
  name: "memory.read",
39
- description: "The facts believed at a moment. validAt asks whether a fact was true then; txAt asks whether the ledger knew it then. Both default to now. Filter by space, subject, predicate. Quarantined (claimed, unconfirmed) facts are left out unless includeClaimed is true.",
40
- inputSchema: { type: "object", properties: { subject: str, predicate: str, space: str, validAt: str, txAt: str, includeClaimed: { type: "boolean" } } },
50
+ description: "The facts believed at a moment. validAt asks whether a fact was true then; txAt asks whether the ledger knew it then. Both default to now. Filter by space, subject, predicate. Quarantined (claimed, unconfirmed) facts are left out unless includeClaimed is true; requireVerified returns only facts whose value the gateway checked against its source.",
51
+ inputSchema: { type: "object", properties: { subject: str, predicate: str, space: str, validAt: str, txAt: str, includeClaimed: { type: "boolean" }, requireVerified: { type: "boolean" } } },
41
52
  },
42
53
  {
43
54
  name: "memory.confirm",
@@ -49,6 +60,31 @@ export const TOOLS = [
49
60
  description: "Undo a belief: the fact leaves the present, stays visible to questions about the past, and whatever it superseded is believed again.",
50
61
  inputSchema: { type: "object", properties: { factId: str, reason: str, actor: str }, required: ["factId", "reason"] },
51
62
  },
63
+ {
64
+ name: "memory.forget",
65
+ description: "Erase a fact's value: from the ledger file, keeping only its digest, and from every store behind the server. The fact stops being believed. The receipt for this call, with the result the gateway observed, is the certificate that the erasure happened.",
66
+ inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
67
+ },
68
+ {
69
+ name: "memory.hold",
70
+ description: "Legal hold: while it stands the fact cannot be forgotten, by request or by retention sweep.",
71
+ inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
72
+ },
73
+ {
74
+ name: "memory.release",
75
+ description: "Lift a legal hold.",
76
+ inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
77
+ },
78
+ {
79
+ name: "memory.sweep",
80
+ description: "Retention: forget every fact the ledger learned of before an instant, in one space or all, skipping held facts, and remove each from every store. The receipt is the record of the sweep.",
81
+ inputSchema: { type: "object", properties: { before: str, space: str, reason: str }, required: ["before", "reason"] },
82
+ },
83
+ {
84
+ name: "memory.get",
85
+ description: "One fact by id, whatever its state: its space, actor, provenance, source receipt, validity. Meant for the gateway's fact lookups, so policy can decide on the fact a write supersedes or a retraction targets.",
86
+ inputSchema: { type: "object", properties: { factId: str }, required: ["factId"] },
87
+ },
52
88
  {
53
89
  name: "memory.history",
54
90
  description: "Every event that touched a fact, oldest first.",
@@ -63,18 +99,57 @@ export function createMemoryServer(ledger, opts = {}) {
63
99
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
64
100
  const meta = (req.params._meta ?? {});
65
101
  const receiptId = typeof meta[RECEIPT_META_KEY] === "string" ? meta[RECEIPT_META_KEY] : null;
102
+ const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
103
+ return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
104
+ });
105
+ async function forgetOne(factId, reason) {
106
+ const fact = ledger.facts().find((f) => f.factId === factId);
107
+ const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt } });
108
+ const removedFrom = [];
109
+ const stillHeld = [];
110
+ for (const store of opts.stores ?? []) {
111
+ const id = fact?.external?.[store.name];
112
+ if (!id)
113
+ continue;
114
+ try {
115
+ await store.remove(id, fact);
116
+ removedFrom.push(store.name);
117
+ }
118
+ catch (e) {
119
+ stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
120
+ }
121
+ }
122
+ return { factId: ev.factId, valueDigest: ev.valueDigest, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
123
+ }
124
+ let currentActor = "anonymous";
125
+ let currentReceipt = null;
126
+ async function handle(name, args, meta, receiptId) {
66
127
  const gatewayAgent = typeof meta[AGENT_META_KEY] === "string" ? meta[AGENT_META_KEY] : null;
67
128
  if (opts.requireGateway && !receiptId)
68
129
  return fail("memory server accepts calls only through the receipts gateway; no receipt id on this call");
69
- const args = req.params.arguments ?? {};
70
130
  const actorFor = (claimed) => gatewayAgent ?? claimed ?? "anonymous";
71
131
  // A write is attested when it came through the gateway: the actor is from a signed grant and the receipt exists.
72
132
  const provenance = receiptId && gatewayAgent ? "attested" : "claimed";
133
+ const observed = (meta[OBSERVED_META_KEY] ?? {});
134
+ currentActor = actorFor(undefined);
135
+ currentReceipt = receiptId;
73
136
  try {
74
- switch (req.params.name) {
137
+ switch (name) {
75
138
  case "memory.write": {
76
139
  const a = Write.parse(args);
77
- const input = { subject: a.subject, predicate: a.predicate, value: a.value ?? null, space: a.space, actor: actorFor(a.actor), source: { receiptId }, provenance, ...(a.validFrom ? { validFrom: a.validFrom } : {}), ...(a.confidence !== undefined ? { confidence: a.confidence } : {}), ...(a.supersedes ? { supersedes: a.supersedes } : {}) };
140
+ let writeProvenance = provenance;
141
+ if (a.evidence) {
142
+ // Value-level quarantine: the agent may say where the value came from, and the gateway's own observation decides.
143
+ if (provenance !== "attested")
144
+ return fail("evidence needs the gateway: only a fact the gateway fetched itself can verify a value");
145
+ if (!(a.evidence.fact in observed))
146
+ return fail(`no fact named "${a.evidence.fact}" was fetched by the gateway for this call; configure a fact lookup for memory.write`);
147
+ const expected = a.evidence.path ? a.evidence.path.split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), observed[a.evidence.fact]) : observed[a.evidence.fact];
148
+ if (JSON.stringify(sortKeys(expected)) !== JSON.stringify(sortKeys(a.value ?? null)))
149
+ return fail(`value differs from what the gateway observed in "${a.evidence.fact}${a.evidence.path ? "." + a.evidence.path : ""}"; write refused`);
150
+ writeProvenance = "verified";
151
+ }
152
+ const input = { subject: a.subject, predicate: a.predicate, value: a.value ?? null, space: a.space, actor: actorFor(a.actor), source: { receiptId }, provenance: writeProvenance, ...(a.validFrom ? { validFrom: a.validFrom } : {}), ...(a.confidence !== undefined ? { confidence: a.confidence } : {}), ...(a.supersedes ? { supersedes: a.supersedes } : {}) };
78
153
  // The stores are written first, so their ids can be recorded on the fact; the ledger's checks run beforehand
79
154
  // so a write the ledger would refuse never reaches a store.
80
155
  ledger.validateAssert(input);
@@ -86,8 +161,9 @@ export function createMemoryServer(ledger, opts = {}) {
86
161
  return json({ fact: ev.fact, eventId: ev.eventId, txTime: ev.txTime, supersedes: ev.supersedes });
87
162
  }
88
163
  case "memory.read": {
89
- const { includeClaimed, ...q } = Read.parse(args);
90
- return json({ facts: ledger.asOf({ ...q, include: includeClaimed ? "all" : "attested" }) });
164
+ const { includeClaimed, requireVerified, ...q } = Read.parse(args);
165
+ const facts = ledger.asOf({ ...q, include: requireVerified ? "verified" : includeClaimed ? "all" : "attested" });
166
+ return { ...json({ facts }), _meta: { [FACTS_META_KEY]: facts.map((f) => f.factId) } };
91
167
  }
92
168
  case "memory.retract": {
93
169
  const a = Retract.parse(args);
@@ -119,16 +195,62 @@ export function createMemoryServer(ledger, opts = {}) {
119
195
  const ev = ledger.confirm({ factId: a.factId, actor: actorFor(undefined), source: { receiptId } });
120
196
  return json({ eventId: ev.eventId, factId: ev.factId, txTime: ev.txTime, actor: ev.actor, source: ev.source });
121
197
  }
198
+ case "memory.forget": {
199
+ const a = Forget.parse(args);
200
+ const out = await forgetOne(a.factId, a.reason);
201
+ if (out.stillHeld.length > 0)
202
+ return { isError: true, content: [{ type: "text", text: `erased from the ledger, but still held by ${out.stillHeld.join("; ")}` }, { type: "text", text: JSON.stringify(out) }] };
203
+ return json(out);
204
+ }
205
+ case "memory.hold": {
206
+ const a = Hold.parse(args);
207
+ return json(ledger.hold({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
208
+ }
209
+ case "memory.release": {
210
+ const a = Hold.parse(args);
211
+ return json(ledger.release({ factId: a.factId, actor: actorFor(undefined), reason: a.reason, source: { receiptId } }));
212
+ }
213
+ case "memory.sweep": {
214
+ const a = Sweep.parse(args);
215
+ const targets = ledger.facts().filter((f) => !f.forgotten && (a.space === undefined || f.space === a.space));
216
+ const learnedBefore = new Set(ledger.facts().filter((f) => ledger.history(f.factId).find((e) => e.kind === "assert" && e.fact.factId === f.factId).txTime < a.before).map((f) => f.factId));
217
+ const forgotten = [];
218
+ const held = [];
219
+ for (const f of targets) {
220
+ if (!learnedBefore.has(f.factId))
221
+ continue;
222
+ if (ledger.held(f.factId)) {
223
+ held.push(f.factId);
224
+ continue;
225
+ }
226
+ forgotten.push(await forgetOne(f.factId, a.reason));
227
+ }
228
+ const stillHeld = forgotten.flatMap((o) => o.stillHeld);
229
+ const out = { before: a.before, space: a.space ?? null, forgotten: forgotten.map((o) => ({ factId: o.factId, valueDigest: o.valueDigest, removedFrom: o.removedFrom })), held, stillHeld };
230
+ if (stillHeld.length > 0)
231
+ 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) }] };
232
+ return json(out);
233
+ }
234
+ case "memory.get": {
235
+ const a = Get.parse(args);
236
+ const f = ledger.facts().find((x) => x.factId === a.factId);
237
+ if (!f)
238
+ return fail(`unknown fact ${a.factId}`);
239
+ const retracted = ledger.history(a.factId).some((e) => e.kind === "retract");
240
+ // Cedar has no null: absent fields stay absent, so a policy tests them with `has`.
241
+ const clean = Object.fromEntries(Object.entries({ ...f, source: f.source.receiptId ?? undefined, retracted }).filter(([, v]) => v !== null && v !== undefined));
242
+ return json(clean);
243
+ }
122
244
  case "memory.history":
123
245
  return json({ events: ledger.history(History.parse(args).factId) });
124
246
  default:
125
- return fail(`unknown tool ${req.params.name}`);
247
+ return fail(`unknown tool ${name}`);
126
248
  }
127
249
  }
128
250
  catch (e) {
129
251
  return fail(e instanceof z.ZodError ? `invalid arguments: ${e.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}` : String(e instanceof Error ? e.message : e));
130
252
  }
131
- });
253
+ }
132
254
  return server;
133
255
  }
134
256
  /** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
@@ -138,3 +260,14 @@ export async function serveStdio(server) {
138
260
  server.onclose = resolve;
139
261
  });
140
262
  }
263
+ function sortKeys(v) {
264
+ if (Array.isArray(v))
265
+ return v.map(sortKeys);
266
+ if (v && typeof v === "object") {
267
+ const o = {};
268
+ for (const k of Object.keys(v).sort())
269
+ o[k] = sortKeys(v[k]);
270
+ return o;
271
+ }
272
+ return v;
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/state",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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.4",
36
+ "@agent-custody/receipts": "0.1.6",
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
38
  "zod": "^4.5.4"
39
39
  },