@agent-custody/state 0.1.6 → 0.1.7

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
@@ -74,19 +74,19 @@ The lookup for writes is optional, so a write that supersedes nothing needs no l
74
74
 
75
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.
76
76
 
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.
77
+ [Writing policies](../receipts/docs/policies.md#policies-for-memory) in the receipts guide has six tested policies for the memory tools, from confining an agent to its team's space to a complete support-agent policy. 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.
78
78
 
79
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.
80
80
 
81
81
  ## Certified forget
82
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.
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, stops believing the fact, and removes it from every store behind the server. What it keeps in place of the value is a choice, recorded on the event as `digestKind`: a plain `sha256` of the value, which lets the ledger prove what it erased but is guessable for short values such as an email by anyone holding the file; an `hmac-sha256` under a forget key the server holds outside the file (`serve --forget-key-env NAME`), which proves the same to anyone shown the key and nothing to anyone else; or `none`, with `keepDigest: false`, for the case where counsel wants nothing derived from the value retained. Use the keyed form unless you have a reason not to. 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
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.
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. 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
86
 
87
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
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.
89
+ 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.
90
90
 
91
91
  ## Blast radius
92
92
 
@@ -162,7 +162,7 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
162
162
  src/ledger.ts the fact record, the two event kinds, as-of queries, supersession, retraction, JSONL persistence
163
163
  src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
164
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
165
+ src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), blast
166
166
  src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
167
167
  src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
168
168
  src/evals.ts the memory-mutation harness: scenarios, scoring, report
@@ -179,6 +179,8 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
179
179
 
180
180
  - Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
181
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
+ - Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
183
+ - 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.
182
184
  - 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
185
  - 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
186
  - 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.
@@ -192,5 +194,8 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
192
194
 
193
195
  **Next, in the order it pays off**
194
196
 
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.
197
+ 1. A storage interface for the ledger with SQLite as the first alternative to JSONL, for durability, concurrent readers, and indexed queries at scale; JSONL stays the default and the auditable export. [Issue #1](https://github.com/ch4r10t33r/agent-custody/issues/1).
198
+ 2. Write-through adapters for Letta, LangMem, and Cognee, one per user who asks. [Issue #4](https://github.com/ch4r10t33r/agent-custody/issues/4).
199
+ 3. The eval harness as a CLI with customer scenario files and a signed report. [Issue #5](https://github.com/ch4r10t33r/agent-custody/issues/5).
200
+ 4. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
196
201
 
package/dist/cli.js CHANGED
@@ -5,17 +5,43 @@ import { createMemoryServer, serveStdio } from "./server.js";
5
5
  import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
6
6
  import { serveMemoryHttp } from "./http.js";
7
7
  import { loadPrivateKey } from "@agent-custody/receipts";
8
+ import { fileURLToPath } from "node:url";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
11
+ /** The forget key from an environment variable, when named; a named but unset variable is an error at startup. */
12
+ function forgetKeyFrom(envName) {
13
+ if (!envName)
14
+ return {};
15
+ const v = process.env[envName];
16
+ if (!v)
17
+ throw new Error(`forget key: environment variable ${envName} is not set`);
18
+ return { forgetKey: v };
19
+ }
20
+ /** "org=P365D,team:*=P90D" into retention windows. */
21
+ function parseRetention(spec) {
22
+ const out = {};
23
+ for (const part of spec.split(",")) {
24
+ const [pattern, duration] = part.split("=").map((x) => x.trim());
25
+ if (!pattern || !duration)
26
+ throw new Error(`retention: expected space=duration, got "${part}"`);
27
+ out[pattern] = duration;
28
+ }
29
+ return out;
30
+ }
8
31
  const USAGE = `agent-custody-memory <command>
9
32
 
10
- serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>]
33
+ serve --ledger <ledger.jsonl> [--allow-direct] [--key <memory.key>] [--forget-key-env NAME] [--retention 'org=P365D,team:*=P90D']
34
+ --forget-key-env names a secret kept outside the ledger; forgotten values then leave an HMAC, not a guessable hash.
11
35
  the memory server over stdio; run it as the receipts gateway's upstream.
12
36
  --key signs every result for its receipt, so executions verify as attested by this server.
13
37
  By default it refuses calls that did not come through the gateway.
14
38
  serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
15
39
  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.
40
+ sweep --via <gateway.json> --reason <text> [--before <ISO instant>] [--space <space>] [--no-digest]
41
+ retention as a receipted call: runs memory.sweep through that gateway, as the principal in its grant.
42
+ Without --before, the memory server's --retention windows decide. Put this on a timer.
43
+ sweep --ledger <ledger.jsonl> --before <ISO instant> [--space <space>] --reason <text> [--actor <id>] [--forget-key-env NAME] [--no-digest]
44
+ retention on the ledger file alone, for ledgers with no gateway or stores in front of them.
19
45
  blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
20
46
  everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
21
47
  `;
@@ -23,33 +49,53 @@ async function main(argv) {
23
49
  const [cmd, ...rest] = argv;
24
50
  switch (cmd) {
25
51
  case "serve": {
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" } } });
52
+ 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" }, "forget-key-env": { type: "string" }, retention: { type: "string" } } });
27
53
  if (!values.ledger)
28
54
  throw new Error("serve needs --ledger");
29
- const ledger = new Ledger(values.ledger);
55
+ const ledger = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"]));
30
56
  const identity = values.key ? loadPrivateKey(values.key) : undefined;
57
+ const retention = values.retention ? parseRetention(values.retention) : undefined;
58
+ const common = { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}), ...(retention ? { retention } : {}) };
31
59
  if (values.http) {
32
60
  const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
33
61
  if (values["token-env"] && !token)
34
62
  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 } : {}) });
63
+ const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, ...common, ...(token ? { tokens: [token] } : {}) });
36
64
  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
65
  await new Promise((resolve) => process.once("SIGINT", resolve));
38
66
  await running.close();
39
67
  return 0;
40
68
  }
41
69
  console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.size} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
42
- await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}) }));
70
+ await serveStdio(createMemoryServer(ledger, common));
43
71
  return 0;
44
72
  }
45
73
  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" } } });
74
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, via: { type: "string" }, before: { type: "string" }, space: { type: "string" }, reason: { type: "string" }, actor: { type: "string", default: "cli" }, "forget-key-env": { type: "string" }, "no-digest": { type: "boolean", default: false } } });
75
+ if (values.via) {
76
+ // Retention as a receipted call: spawn the gateway from its config and call memory.sweep through it, so the
77
+ // sweep runs as the principal named in that gateway's grant and its receipt is the record.
78
+ if (!values.reason)
79
+ throw new Error("sweep --via needs --reason");
80
+ const gatewayCli = fileURLToPath(import.meta.resolve("@agent-custody/receipts/cli"));
81
+ const client = new Client({ name: "agent-custody-memory-sweep", version: "0" });
82
+ await client.connect(new StdioClientTransport({ command: process.execPath, args: [gatewayCli, "gateway", "--config", values.via], stderr: "inherit" }));
83
+ try {
84
+ const r = await client.callTool({ name: "memory.sweep", arguments: { reason: values.reason, ...(values.before ? { before: new Date(values.before).toISOString() } : {}), ...(values.space ? { space: values.space } : {}), ...(values["no-digest"] ? { keepDigest: false } : {}) } });
85
+ const text = (r.content.find((c) => c.type === "text")?.text) ?? "";
86
+ console.log(text);
87
+ return r.isError ? 1 : 0;
88
+ }
89
+ finally {
90
+ await client.close();
91
+ }
92
+ }
47
93
  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 });
94
+ throw new Error("sweep needs --ledger, --before, and --reason, or --via <gateway.json> --reason");
95
+ const r = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"])).sweep({ before: new Date(values.before).toISOString(), ...(values.space ? { space: values.space } : {}), actor: values.actor, reason: values.reason, ...(values["no-digest"] ? { keepDigest: false } : {}) });
50
96
  console.log(`forgot ${r.forgotten.length} fact(s); ${r.held.length} on hold, kept`);
51
97
  for (const f of r.forgotten)
52
- console.log(` ${f.factId} digest ${f.valueDigest.slice(0, 12)}`);
98
+ console.log(` ${f.factId} ${f.digestKind}${f.valueDigest ? ` ${f.valueDigest.slice(0, 12)}` : ""}`);
53
99
  return 0;
54
100
  }
55
101
  case "blast": {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Ledger } from "./ledger.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";
2
+ export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, DigestKind, 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, durationMs, retentionCutoff, 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";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Public surface of @agent-custody/state.
2
2
  export { Ledger } from "./ledger.js";
3
- export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_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, durationMs, retentionCutoff, 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";
package/dist/ledger.d.ts CHANGED
@@ -22,9 +22,10 @@ export interface Fact {
22
22
  actor: string;
23
23
  source: Source;
24
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 */
25
+ /** present once the value has been erased: the value field is null and this is the digest of what it was, or null when none was kept */
26
26
  forgotten?: {
27
- valueDigest: string;
27
+ valueDigest: string | null;
28
+ digestKind: DigestKind;
28
29
  at: string;
29
30
  };
30
31
  /** ids of this fact in the retrieval stores it was written through to, by store name; absent when there are none */
@@ -61,6 +62,11 @@ export interface ConfirmEvent {
61
62
  actor: string;
62
63
  source: Source;
63
64
  }
65
+ /**
66
+ * How a forgotten value's digest was made. sha256 is guessable for short values by anyone holding the file;
67
+ * hmac-sha256 needs the ledger's forget key, kept outside the file; none keeps nothing derived from the value.
68
+ */
69
+ export type DigestKind = "sha256" | "hmac-sha256" | "none";
64
70
  /**
65
71
  * A forget is erasure, not correction. The fact's value is removed from the ledger file itself and replaced by its
66
72
  * digest, so the ledger can still prove which value it held without holding it. The fact stops being believed.
@@ -73,8 +79,9 @@ export interface ForgetEvent {
73
79
  actor: string;
74
80
  reason: string;
75
81
  source: Source;
76
- /** sha256 of the canonical JSON of the erased value */
77
- valueDigest: string;
82
+ /** digest of the canonical JSON of the erased value, per digestKind; null when none was kept */
83
+ valueDigest: string | null;
84
+ digestKind: DigestKind;
78
85
  }
79
86
  /** A legal hold: while it stands, the fact cannot be forgotten, by request or by retention sweep. Release lifts it. */
80
87
  export interface HoldEvent {
@@ -111,6 +118,8 @@ export interface ForgetInput {
111
118
  actor: string;
112
119
  reason: string;
113
120
  source?: Source;
121
+ /** false keeps no digest at all; default true */
122
+ keepDigest?: boolean;
114
123
  }
115
124
  export interface HoldInput {
116
125
  factId: string;
@@ -125,6 +134,7 @@ export interface SweepInput {
125
134
  actor: string;
126
135
  reason: string;
127
136
  source?: Source;
137
+ keepDigest?: boolean;
128
138
  }
129
139
  export interface RetractInput {
130
140
  factId: string;
@@ -147,8 +157,11 @@ export declare class Ledger {
147
157
  private readonly events;
148
158
  private readonly file;
149
159
  private readonly now;
160
+ private readonly forgetKey;
161
+ /** forgetKey: a secret kept outside the file; with it, forgotten values leave an HMAC rather than a plain hash. */
150
162
  constructor(file: string, opts?: {
151
163
  now?: () => Date;
164
+ forgetKey?: string | Buffer;
152
165
  });
153
166
  get size(): number;
154
167
  /** The checks assert makes, without appending. For callers that must do something irreversible before the append. */
package/dist/ledger.js CHANGED
@@ -2,7 +2,7 @@
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 { createHash } from "node:crypto";
5
+ import { createHash, createHmac } from "node:crypto";
6
6
  import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  const RANK = { claimed: 0, attested: 1, verified: 2 };
@@ -10,9 +10,12 @@ export class Ledger {
10
10
  events = [];
11
11
  file;
12
12
  now;
13
+ forgetKey;
14
+ /** forgetKey: a secret kept outside the file; with it, forgotten values leave an HMAC rather than a plain hash. */
13
15
  constructor(file, opts = {}) {
14
16
  this.file = file;
15
17
  this.now = opts.now ?? (() => new Date());
18
+ this.forgetKey = opts.forgetKey ? Buffer.from(opts.forgetKey) : null;
16
19
  if (existsSync(file)) {
17
20
  for (const line of readFileSync(file, "utf8").split("\n")) {
18
21
  if (line.trim())
@@ -139,7 +142,7 @@ export class Ledger {
139
142
  held.push(e.fact.factId);
140
143
  continue;
141
144
  }
142
- forgotten.push(this.forget({ factId: e.fact.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}) }));
145
+ forgotten.push(this.forget({ factId: e.fact.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}), ...(input.keepDigest === undefined ? {} : { keepDigest: input.keepDigest }) }));
143
146
  }
144
147
  return { forgotten, held };
145
148
  }
@@ -152,14 +155,16 @@ export class Ledger {
152
155
  if (this.held(input.factId))
153
156
  throw new Error(`fact ${input.factId} is on legal hold; release it first`);
154
157
  const txTime = this.now().toISOString();
155
- const valueDigest = createHash("sha256").update(canonical(prior.fact.value)).digest("hex");
158
+ const text = canonical(prior.fact.value);
159
+ const digestKind = input.keepDigest === false ? "none" : this.forgetKey ? "hmac-sha256" : "sha256";
160
+ const valueDigest = digestKind === "none" ? null : digestKind === "hmac-sha256" ? createHmac("sha256", this.forgetKey).update(text).digest("hex") : createHash("sha256").update(text).digest("hex");
156
161
  for (const e of this.events) {
157
162
  if (e.kind === "assert" && e.fact.factId === input.factId) {
158
163
  e.fact.value = null;
159
- e.fact.forgotten = { valueDigest, at: txTime };
164
+ e.fact.forgotten = { valueDigest, digestKind, at: txTime };
160
165
  }
161
166
  }
162
- const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest };
167
+ const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest, digestKind };
163
168
  this.events.push(event);
164
169
  const tmp = `${this.file}.tmp`;
165
170
  writeFileSync(tmp, this.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
package/dist/server.d.ts CHANGED
@@ -19,7 +19,13 @@ export interface MemoryServerOptions {
19
19
  stores?: Store[];
20
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
21
  identity?: KeyPair;
22
+ /** Retention windows by space pattern, ISO 8601 durations: { "org": "P365D", "team:*": "P90D" }. memory.sweep without `before` uses them. */
23
+ retention?: Record<string, string>;
22
24
  }
25
+ /** Parses the ISO 8601 duration subset retention needs: P<n>W, P<n>D, PT<n>H, and combinations of D and H. */
26
+ export declare function durationMs(iso: string): number;
27
+ /** The retention cutoff for a space, from the first pattern that matches it; null when none does. */
28
+ export declare function retentionCutoff(retention: Record<string, string>, space: string, now: Date): string | null;
23
29
  export declare function createMemoryServer(ledger: Ledger, opts?: MemoryServerOptions): Server;
24
30
  /** Serves over stdio, the way the gateway spawns it. Diagnostics must go to stderr. */
25
31
  export declare function serveStdio(server: Server): Promise<void>;
package/dist/server.js CHANGED
@@ -35,9 +35,9 @@ const Confirm = z.object({ factId: z.string().min(1) });
35
35
  const Retract = z.object({ factId: z.string().min(1), reason: z.string().min(1), actor: z.string().min(1).optional() });
36
36
  const History = z.object({ factId: z.string().min(1) });
37
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) });
38
+ const Forget = z.object({ factId: z.string().min(1), reason: z.string().min(1), keepDigest: z.boolean().optional() });
39
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) });
40
+ const Sweep = z.object({ before: iso.optional(), space: z.string().min(1).optional(), reason: z.string().min(1), keepDigest: z.boolean().optional() });
41
41
  const str = { type: "string" };
42
42
  export const TOOLS = [
43
43
  {
@@ -62,8 +62,8 @@ export const TOOLS = [
62
62
  },
63
63
  {
64
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"] },
65
+ description: "Erase a fact's value: from the ledger file, keeping only its digest (keyed when the server has a forget key; none when keepDigest is false), 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, keepDigest: { type: "boolean" } }, required: ["factId", "reason"] },
67
67
  },
68
68
  {
69
69
  name: "memory.hold",
@@ -77,8 +77,8 @@ export const TOOLS = [
77
77
  },
78
78
  {
79
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"] },
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. Without `before`, the server's configured retention windows decide per space. The receipt is the record of the sweep.",
81
+ inputSchema: { type: "object", properties: { before: str, space: str, reason: str, keepDigest: { type: "boolean" } }, required: ["reason"] },
82
82
  },
83
83
  {
84
84
  name: "memory.get",
@@ -91,6 +91,23 @@ export const TOOLS = [
91
91
  inputSchema: { type: "object", properties: { factId: str }, required: ["factId"] },
92
92
  },
93
93
  ];
94
+ /** Parses the ISO 8601 duration subset retention needs: P<n>W, P<n>D, PT<n>H, and combinations of D and H. */
95
+ export function durationMs(iso) {
96
+ const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/.exec(iso);
97
+ if (!m || iso === "P" || iso === "PT")
98
+ throw new Error(`retention: cannot parse duration ${iso}; use forms like P90D, P2W, PT12H`);
99
+ const [, w = "0", d = "0", h = "0", min = "0"] = m;
100
+ return ((Number(w) * 7 + Number(d)) * 24 + Number(h)) * 3_600_000 + Number(min) * 60_000;
101
+ }
102
+ /** The retention cutoff for a space, from the first pattern that matches it; null when none does. */
103
+ export function retentionCutoff(retention, space, now) {
104
+ for (const [pattern, duration] of Object.entries(retention)) {
105
+ const matches = pattern.endsWith("*") ? space.startsWith(pattern.slice(0, -1)) : space === pattern;
106
+ if (matches)
107
+ return new Date(now.getTime() - durationMs(duration)).toISOString();
108
+ }
109
+ return null;
110
+ }
94
111
  const json = (v) => ({ content: [{ type: "text", text: JSON.stringify(v) }] });
95
112
  const fail = (msg) => ({ isError: true, content: [{ type: "text", text: msg }] });
96
113
  export function createMemoryServer(ledger, opts = {}) {
@@ -102,9 +119,9 @@ export function createMemoryServer(ledger, opts = {}) {
102
119
  const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
103
120
  return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
104
121
  });
105
- async function forgetOne(factId, reason) {
122
+ async function forgetOne(factId, reason, keepDigest) {
106
123
  const fact = ledger.facts().find((f) => f.factId === factId);
107
- const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt } });
124
+ const ev = ledger.forget({ factId, actor: currentActor, reason, source: { receiptId: currentReceipt }, ...(keepDigest === undefined ? {} : { keepDigest }) });
108
125
  const removedFrom = [];
109
126
  const stillHeld = [];
110
127
  for (const store of opts.stores ?? []) {
@@ -119,7 +136,7 @@ export function createMemoryServer(ledger, opts = {}) {
119
136
  stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
120
137
  }
121
138
  }
122
- return { factId: ev.factId, valueDigest: ev.valueDigest, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
139
+ return { factId: ev.factId, valueDigest: ev.valueDigest, digestKind: ev.digestKind, txTime: ev.txTime, actor: ev.actor, reason: ev.reason, source: ev.source, erasedFromLedger: true, removedFrom, stillHeld };
123
140
  }
124
141
  let currentActor = "anonymous";
125
142
  let currentReceipt = null;
@@ -197,7 +214,7 @@ export function createMemoryServer(ledger, opts = {}) {
197
214
  }
198
215
  case "memory.forget": {
199
216
  const a = Forget.parse(args);
200
- const out = await forgetOne(a.factId, a.reason);
217
+ const out = await forgetOne(a.factId, a.reason, a.keepDigest);
201
218
  if (out.stillHeld.length > 0)
202
219
  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
220
  return json(out);
@@ -212,8 +229,15 @@ export function createMemoryServer(ledger, opts = {}) {
212
229
  }
213
230
  case "memory.sweep": {
214
231
  const a = Sweep.parse(args);
232
+ if (!a.before && !opts.retention)
233
+ return fail("sweep needs `before`, or a server started with retention windows");
234
+ const now = new Date();
235
+ const cutoffFor = (space) => a.before ?? retentionCutoff(opts.retention ?? {}, space, now);
215
236
  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));
237
+ const learnedBefore = new Set(ledger.facts().filter((f) => {
238
+ const cutoff = cutoffFor(f.space);
239
+ return cutoff !== null && ledger.history(f.factId).find((e) => e.kind === "assert" && e.fact.factId === f.factId).txTime < cutoff;
240
+ }).map((f) => f.factId));
217
241
  const forgotten = [];
218
242
  const held = [];
219
243
  for (const f of targets) {
@@ -223,10 +247,10 @@ export function createMemoryServer(ledger, opts = {}) {
223
247
  held.push(f.factId);
224
248
  continue;
225
249
  }
226
- forgotten.push(await forgetOne(f.factId, a.reason));
250
+ forgotten.push(await forgetOne(f.factId, a.reason, a.keepDigest));
227
251
  }
228
252
  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 };
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 };
230
254
  if (stillHeld.length > 0)
231
255
  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
256
  return json(out);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/state",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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.6",
36
+ "@agent-custody/receipts": "0.1.7",
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
38
  "zod": "^4.5.4"
39
39
  },