@agent-custody/state 0.1.5 → 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 +14 -7
- package/dist/cli.js +13 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ledger.d.ts +43 -7
- package/dist/ledger.js +46 -2
- package/dist/server.d.ts +2 -0
- package/dist/server.js +104 -23
- package/package.json +2 -2
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
|
-
|
|
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
|
|
|
@@ -45,10 +45,14 @@ In the gateway's config, the memory server is the upstream, and the grant names
|
|
|
45
45
|
| `memory.confirm` | lifts a quarantined fact to attested; accepted only through the gateway | `factId` |
|
|
46
46
|
| `memory.retract` | undoes a belief, keeping it visible to questions about the past | `factId`, `reason` |
|
|
47
47
|
| `memory.forget` | erases the value from the ledger and every store, keeping the digest; the receipt is the certificate | `factId`, `reason` |
|
|
48
|
+
| `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` |
|
|
48
50
|
| `memory.get` | one fact by id in any state, for the gateway's policy lookups | `factId` |
|
|
49
51
|
| `memory.history` | every event that touched a fact | `factId` |
|
|
50
52
|
|
|
51
|
-
**Quarantine.** Every fact carries a provenance
|
|
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.
|
|
52
56
|
|
|
53
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:
|
|
54
58
|
|
|
@@ -78,6 +82,8 @@ Trust tiers are Cedar policies over the space and, through `includeClaimed`, ove
|
|
|
78
82
|
|
|
79
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.
|
|
80
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
|
+
|
|
81
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.
|
|
82
88
|
|
|
83
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.
|
|
@@ -101,6 +107,8 @@ retracted at 2026-09-07T10:12:04.118Z by support-agent: CRM sync bug: account is
|
|
|
101
107
|
acct:42 support_tier = "priority" fact e77d… STILL BELIEVED
|
|
102
108
|
```
|
|
103
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
|
+
|
|
104
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.
|
|
105
113
|
|
|
106
114
|
## Write-through to the stores you already use
|
|
@@ -154,7 +162,7 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
|
|
|
154
162
|
src/ledger.ts the fact record, the two event kinds, as-of queries, supersession, retraction, JSONL persistence
|
|
155
163
|
src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
|
|
156
164
|
src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
|
|
157
|
-
src/cli.ts agent-custody-memory serve (stdio or --http), blast
|
|
165
|
+
src/cli.ts agent-custody-memory serve (stdio or --http), sweep, blast
|
|
158
166
|
src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
|
|
159
167
|
src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
|
|
160
168
|
src/evals.ts the memory-mutation harness: scenarios, scoring, report
|
|
@@ -171,6 +179,8 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
171
179
|
|
|
172
180
|
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL.
|
|
173
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.
|
|
174
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.
|
|
175
185
|
- The memory server over HTTP: one ledger shared by several gateways and direct writers, bearer-token auth, quarantine live.
|
|
176
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.
|
|
@@ -182,8 +192,5 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
182
192
|
|
|
183
193
|
**Next, in the order it pays off**
|
|
184
194
|
|
|
185
|
-
1.
|
|
186
|
-
2. Retention windows and legal hold on the ledger: forget on a schedule, and a hold that refuses forget for named facts until lifted.
|
|
187
|
-
3. A gateway with several upstreams under one grant, so memory and the tools an agent acts with share a session and blast radius reaches the emails sent, not only the beliefs written.
|
|
188
|
-
4. The memory tools from Python, through the sidecar, so SDK-only Python agents can write claimed facts to a shared ledger.
|
|
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.
|
|
189
196
|
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,9 @@ const USAGE = `agent-custody-memory <command>
|
|
|
13
13
|
By default it refuses calls that did not come through the gateway.
|
|
14
14
|
serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
|
|
15
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.
|
|
16
19
|
blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
|
|
17
20
|
everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
|
|
18
21
|
`;
|
|
@@ -39,6 +42,16 @@ async function main(argv) {
|
|
|
39
42
|
await serveStdio(createMemoryServer(ledger, { requireGateway: !values["allow-direct"], ...(identity ? { identity } : {}) }));
|
|
40
43
|
return 0;
|
|
41
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
|
+
}
|
|
42
55
|
case "blast": {
|
|
43
56
|
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
44
57
|
if (!values.ledger || !values.receipts || !values.fact)
|
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, LedgerEvent, RetractEvent, RetractInput, Source } from "./ledger.ts";
|
|
3
|
-
export { AGENT_META_KEY, FACTS_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";
|
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, 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";
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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" | "
|
|
13
|
+
export type FactProvenance = "claimed" | "attested" | "verified";
|
|
11
14
|
export interface Fact {
|
|
12
15
|
factId: string;
|
|
13
16
|
subject: string;
|
|
@@ -73,7 +76,17 @@ export interface ForgetEvent {
|
|
|
73
76
|
/** sha256 of the canonical JSON of the erased value */
|
|
74
77
|
valueDigest: string;
|
|
75
78
|
}
|
|
76
|
-
|
|
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;
|
|
77
90
|
export interface AssertInput {
|
|
78
91
|
subject: string;
|
|
79
92
|
predicate: string;
|
|
@@ -99,6 +112,20 @@ export interface ForgetInput {
|
|
|
99
112
|
reason: string;
|
|
100
113
|
source?: Source;
|
|
101
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
|
+
}
|
|
102
129
|
export interface RetractInput {
|
|
103
130
|
factId: string;
|
|
104
131
|
actor: string;
|
|
@@ -113,8 +140,8 @@ export interface AsOf {
|
|
|
113
140
|
space?: string;
|
|
114
141
|
subject?: string;
|
|
115
142
|
predicate?: string;
|
|
116
|
-
/** "attested"
|
|
117
|
-
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";
|
|
118
145
|
}
|
|
119
146
|
export declare class Ledger {
|
|
120
147
|
private readonly events;
|
|
@@ -134,6 +161,15 @@ export declare class Ledger {
|
|
|
134
161
|
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
135
162
|
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
136
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
|
+
};
|
|
137
173
|
forget(input: ForgetInput): ForgetEvent;
|
|
138
174
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
139
175
|
asOf(q?: AsOf): Fact[];
|
package/dist/ledger.js
CHANGED
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
|
+
const RANK = { claimed: 0, attested: 1, verified: 2 };
|
|
8
9
|
export class Ledger {
|
|
9
10
|
events = [];
|
|
10
11
|
file;
|
|
@@ -90,7 +91,7 @@ export class Ledger {
|
|
|
90
91
|
throw new Error(`cannot confirm unknown fact ${input.factId}`);
|
|
91
92
|
if (this.retractedAt(input.factId))
|
|
92
93
|
throw new Error(`fact ${input.factId} is retracted`);
|
|
93
|
-
if (prior.fact.provenance
|
|
94
|
+
if (prior.fact.provenance !== "claimed" || this.confirmedAt(input.factId))
|
|
94
95
|
throw new Error(`fact ${input.factId} is already attested`);
|
|
95
96
|
const event = { eventId: randomUUID(), kind: "confirm", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, source: input.source ?? { receiptId: null } };
|
|
96
97
|
this.append(event);
|
|
@@ -101,12 +102,55 @@ export class Ledger {
|
|
|
101
102
|
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
102
103
|
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
103
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
|
+
}
|
|
104
146
|
forget(input) {
|
|
105
147
|
const prior = this.factById(input.factId);
|
|
106
148
|
if (!prior)
|
|
107
149
|
throw new Error(`cannot forget unknown fact ${input.factId}`);
|
|
108
150
|
if (prior.fact.forgotten)
|
|
109
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`);
|
|
110
154
|
const txTime = this.now().toISOString();
|
|
111
155
|
const valueDigest = createHash("sha256").update(canonical(prior.fact.value)).digest("hex");
|
|
112
156
|
for (const e of this.events) {
|
|
@@ -144,7 +188,7 @@ export class Ledger {
|
|
|
144
188
|
(q.space === undefined || f.space === q.space) &&
|
|
145
189
|
(q.subject === undefined || f.subject === q.subject) &&
|
|
146
190
|
(q.predicate === undefined || f.predicate === q.predicate) &&
|
|
147
|
-
(q.include
|
|
191
|
+
(q.include === undefined || q.include === "all" || RANK[f.provenance] >= RANK[q.include]));
|
|
148
192
|
}
|
|
149
193
|
/** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
|
|
150
194
|
facts() {
|
package/dist/server.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
|
9
9
|
export declare const AGENT_META_KEY = "agent-custody/agent";
|
|
10
10
|
/** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
|
|
11
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";
|
|
12
14
|
export declare const TOOLS: Tool[];
|
|
13
15
|
export interface MemoryServerOptions {
|
|
14
16
|
/** Refuse calls that did not come through the gateway, i.e. carry no receipt id. On by default when served from the CLI. */
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,8 @@ export const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
|
14
14
|
export const AGENT_META_KEY = "agent-custody/agent";
|
|
15
15
|
/** Set on read results: the ids of the facts served, so the gateway can record what the agent was shown. */
|
|
16
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";
|
|
17
19
|
const iso = z.string().datetime({ offset: true });
|
|
18
20
|
const Write = z.object({
|
|
19
21
|
subject: z.string().min(1),
|
|
@@ -25,24 +27,28 @@ const Write = z.object({
|
|
|
25
27
|
validFrom: iso.optional(),
|
|
26
28
|
confidence: z.number().min(0).max(1).optional(),
|
|
27
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(),
|
|
28
32
|
});
|
|
29
|
-
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() });
|
|
30
34
|
const Confirm = z.object({ factId: z.string().min(1) });
|
|
31
35
|
const Retract = z.object({ factId: z.string().min(1), reason: z.string().min(1), actor: z.string().min(1).optional() });
|
|
32
36
|
const History = z.object({ factId: z.string().min(1) });
|
|
33
37
|
const Get = z.object({ factId: z.string().min(1) });
|
|
34
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) });
|
|
35
41
|
const str = { type: "string" };
|
|
36
42
|
export const TOOLS = [
|
|
37
43
|
{
|
|
38
44
|
name: "memory.write",
|
|
39
|
-
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.",
|
|
40
|
-
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"] },
|
|
41
47
|
},
|
|
42
48
|
{
|
|
43
49
|
name: "memory.read",
|
|
44
|
-
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.",
|
|
45
|
-
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" } } },
|
|
46
52
|
},
|
|
47
53
|
{
|
|
48
54
|
name: "memory.confirm",
|
|
@@ -59,6 +65,21 @@ export const TOOLS = [
|
|
|
59
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.",
|
|
60
66
|
inputSchema: { type: "object", properties: { factId: str, reason: str }, required: ["factId", "reason"] },
|
|
61
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
|
+
},
|
|
62
83
|
{
|
|
63
84
|
name: "memory.get",
|
|
64
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.",
|
|
@@ -81,6 +102,27 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
81
102
|
const result = await handle(req.params.name, req.params.arguments ?? {}, meta, receiptId);
|
|
82
103
|
return opts.identity && receiptId ? signResult(result, opts.identity, receiptId, req.params.name) : result;
|
|
83
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;
|
|
84
126
|
async function handle(name, args, meta, receiptId) {
|
|
85
127
|
const gatewayAgent = typeof meta[AGENT_META_KEY] === "string" ? meta[AGENT_META_KEY] : null;
|
|
86
128
|
if (opts.requireGateway && !receiptId)
|
|
@@ -88,11 +130,26 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
88
130
|
const actorFor = (claimed) => gatewayAgent ?? claimed ?? "anonymous";
|
|
89
131
|
// A write is attested when it came through the gateway: the actor is from a signed grant and the receipt exists.
|
|
90
132
|
const provenance = receiptId && gatewayAgent ? "attested" : "claimed";
|
|
133
|
+
const observed = (meta[OBSERVED_META_KEY] ?? {});
|
|
134
|
+
currentActor = actorFor(undefined);
|
|
135
|
+
currentReceipt = receiptId;
|
|
91
136
|
try {
|
|
92
137
|
switch (name) {
|
|
93
138
|
case "memory.write": {
|
|
94
139
|
const a = Write.parse(args);
|
|
95
|
-
|
|
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 } : {}) };
|
|
96
153
|
// The stores are written first, so their ids can be recorded on the fact; the ledger's checks run beforehand
|
|
97
154
|
// so a write the ledger would refuse never reaches a store.
|
|
98
155
|
ledger.validateAssert(input);
|
|
@@ -104,8 +161,8 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
104
161
|
return json({ fact: ev.fact, eventId: ev.eventId, txTime: ev.txTime, supersedes: ev.supersedes });
|
|
105
162
|
}
|
|
106
163
|
case "memory.read": {
|
|
107
|
-
const { includeClaimed, ...q } = Read.parse(args);
|
|
108
|
-
const 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" });
|
|
109
166
|
return { ...json({ facts }), _meta: { [FACTS_META_KEY]: facts.map((f) => f.factId) } };
|
|
110
167
|
}
|
|
111
168
|
case "memory.retract": {
|
|
@@ -140,25 +197,38 @@ export function createMemoryServer(ledger, opts = {}) {
|
|
|
140
197
|
}
|
|
141
198
|
case "memory.forget": {
|
|
142
199
|
const a = Forget.parse(args);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
150
224
|
continue;
|
|
151
|
-
try {
|
|
152
|
-
await store.remove(id, fact);
|
|
153
|
-
removedFrom.push(store.name);
|
|
154
|
-
}
|
|
155
|
-
catch (e) {
|
|
156
|
-
stillHeld.push(`${store.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
157
225
|
}
|
|
226
|
+
forgotten.push(await forgetOne(f.factId, a.reason));
|
|
158
227
|
}
|
|
159
|
-
const
|
|
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 };
|
|
160
230
|
if (stillHeld.length > 0)
|
|
161
|
-
return { isError: true, content: [{ type: "text", text: `
|
|
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) }] };
|
|
162
232
|
return json(out);
|
|
163
233
|
}
|
|
164
234
|
case "memory.get": {
|
|
@@ -190,3 +260,14 @@ export async function serveStdio(server) {
|
|
|
190
260
|
server.onclose = resolve;
|
|
191
261
|
});
|
|
192
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.
|
|
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.
|
|
36
|
+
"@agent-custody/receipts": "0.1.6",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
38
|
"zod": "^4.5.4"
|
|
39
39
|
},
|