@agent-custody/state 0.2.0 → 0.3.0

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
@@ -82,12 +82,12 @@ The lookup for writes is optional, so a write that supersedes nothing needs no l
82
82
 
83
83
  ## Certified forget
84
84
 
85
+ "Certified" means the receipt certifies what was done, not that the value exists nowhere. What forget reaches is the ledger and the stores with adapters, and it reports what each of them answered. It does not reach caches, a model's context window, application logs, replicas, backups, warehouses fed by export, or any store without an adapter. On Postgres the erased row image stays in the table until the store's `VACUUM FULL` runs, which it does after each forget unless told not to ([the ledger](#the-ledger)). Say all of that plainly to whoever is relying on it, and read the per-store status in the result before calling anything gone.
86
+
85
87
  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.
86
88
 
87
89
  **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.
88
90
 
89
- What forget reaches is the ledger and the stores with adapters. It does not reach caches, a model's context window, application logs, or any store without an adapter. Say that plainly to whoever is relying on it.
90
-
91
91
  **Removal is verified, not assumed.** A delete by id and a search index catching up are different moments. After every retraction, forget, or sweep, the server asks each store's own search whether the fact still surfaces, a few times with backoff, and records the outcome per store in the result and so in the receipt: `verified` (the store's search no longer finds it), `stillIndexed` (it still does, after the retries), `unverified` (the store cannot be asked, or the adapter has no search), or `failed` (the removal itself failed). Mem0 and Zep both verify through their search APIs; the tests drive the real clients against indexes that lag by a configurable number of searches. A certificate that says `stillIndexed` is an honest certificate; run forget again later, or read it as the store's problem to fix.
92
92
 
93
93
  **The pack.** `agent-custody-memory pack --ledger ... --receipts ... --fact <id> --out pack.json --sign keys/pack.key` gathers everything about one fact into one signed artefact: its history with the receipt that produced each event, its holds and releases, its blast radius with every downstream receipt, and, if it was forgotten, the forget certificate with what each store answered. `pack --verify pack.json --key keys/pack.pub --issuer-key keys/gateway.pub --principal-key keys/principal.pub` checks the pack's signature and digest, every receipt inside it against the gateway's keys, that every event's receipt is present, and that the forget receipt names this fact and records the erasure. Touch one receipt inside and the whole pack fails. It is the artefact counsel attaches to a ticket; the reviewer needs the two public keys and nothing from you.
@@ -119,6 +119,29 @@ With the gateway fronting several upstreams, memory and the tools the agent acts
119
119
 
120
120
  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.
121
121
 
122
+ ## Explain one action
123
+
124
+ The question a security owner asks first is not about a fact but about an action: *what is refund 183722, and can I trust the answer?* `agent-custody-memory explain --receipts receipts --receipt <id> --ledger ledger.sqlite --issuer-key keys/gateway.pub --principal-key keys/principal.pub` answers it in the order it is asked:
125
+
126
+ ```
127
+ WHO support-agent (attested, named in a signed grant)
128
+ WHO AUTHORIZED IT user_456, grant signed by key bfa1c0d2e3f4, valid 2026-09-08T08:00:00.000Z to 2026-09-08T16:00:00.000Z
129
+ WHAT WAS ALLOWED tools customer.lookup, stripe.refund
130
+ policy 9c1d2e3f4a5b decided allow
131
+ WHAT THE AGENT SAW customer = {"verified":true,"plan":"pro"} (fetched by the gateway via customer.lookup)
132
+ 1 belief(s) shown before this call:
133
+ acct:42 plan = "enterprise" [0b83d822]
134
+ WHAT IT DID stripe.refund {"customer_id":"cust_123","amount":845000} -> executed
135
+ WHY the policy permitted it
136
+ WHAT EVIDENCE receipt 7f2e…, leaf 41 of a log whose head is signed by 3a9b0c1d2e3f
137
+ authorization committed as leaf 40, before the call was forwarded
138
+ CAN I VERIFY IT VERIFIED, 23 checks
139
+ DID ANYTHING DEPEND ON THIS 1 belief(s) written in this call, 3 later call(s) made after seeing them, 2 belief(s) derived
140
+ WHAT NEEDS REVERSAL 2 belief(s) still believed, and 3 later call(s) to review
141
+ ```
142
+
143
+ The first eight lines come from the receipt alone and work without a ledger; the last two are answered from the ledger, and without one they say `unknown without a ledger` rather than pretending nothing depended on the call. `--out action.json --sign keys/pack.key` writes the same answers as one signed action pack with the receipt and every downstream receipt inside it, and `explain --verify action.json --key keys/pack.pub --issuer-key ... --principal-key ...` checks the pack's signature and digest, the receipt, every downstream receipt, and that the written beliefs cite this receipt. Touch one receipt inside and the pack fails. The custody pack below is the same artefact seen from a fact instead of an action.
144
+
122
145
  ## Write-through to the stores you already use
123
146
 
124
147
  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.
@@ -189,8 +212,9 @@ src/ledger.ts the fact record, the event kinds, as-of queries, supersession, r
189
212
  src/storage.ts the store interface and its three stores: JSONL (default, auditable), SQLite (indexed, one machine), Postgres (shared); chosen by path or URL
190
213
  src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
191
214
  src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
215
+ src/explain.ts one action explained: the ten answers from a receipt and the ledger, and the signed action pack
192
216
  src/pack.ts the custody pack: build, sign, verify, format
193
- src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, pack, export, blast
217
+ src/cli.ts agent-custody-memory serve (stdio or --http, with --retention and --forget-key-env), sweep (ledger-only or --via a gateway), eval, explain, pack, export, blast
194
218
  src/blast.ts blast radius: from receipts' consumed facts and the ledger's source receipts, forward
195
219
  src/stores.ts write-through adapters: Mem0 and Zep, and the Store interface for others
196
220
  src/evals.ts the memory-mutation harness: scenarios, scoring, report
@@ -210,6 +234,7 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
210
234
  - 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.
211
235
  - Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
212
236
  - 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.
237
+ - Explain one action: from a receipt id, who, who authorized it, what was allowed, what the agent saw, what it did, why, the evidence, whether it verifies, what depended on it, and what needs reversal; the same as a signed action pack that carries every downstream receipt.
213
238
  - The custody pack: a fact's history with receipts, holds, blast radius, and forget certificate as one signed artefact, verified as a whole.
214
239
  - Removal verification: after a retraction, forget, or sweep the server asks each store's search whether the value still surfaces and records verified, stillIndexed, unverified, or failed per store, in the receipt.
215
240
  - 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.
@@ -226,6 +251,6 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
226
251
 
227
252
  **Next, in the order it pays off**
228
253
 
229
- 1. Write-through adapters for Letta, LangMem, and Cognee, one per user who asks. [Issue #4](https://github.com/ch4r10t33r/agent-custody/issues/4).
230
- 2. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane, with SSO, SCIM, residency, and SIEM export. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
254
+ 1. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane, with SSO, SCIM, residency, and SIEM export. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
255
+ 2. Write-through adapters for Letta, LangMem, and Cognee, one per user who asks. [Issue #4](https://github.com/ch4r10t33r/agent-custody/issues/4).
231
256
 
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { Ledger } from "./ledger.js";
4
4
  import { createMemoryServer, serveStdio } from "./server.js";
5
5
  import { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
6
6
  import { serveMemoryHttp } from "./http.js";
7
- import { loadPrivateKey, loadPublicKey } from "@agent-custody/receipts";
7
+ import { loadPrivateKey, loadPublicKey, verifyBundle } from "@agent-custody/receipts";
8
8
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
@@ -12,6 +12,7 @@ import { formatReport, runAll, SCENARIOS } from "./evals.js";
12
12
  import { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
13
13
  import { loadScenarios, signReport, verifyReport } from "./evals-file.js";
14
14
  import { buildPack, formatPack, signPack, verifyPack } from "./pack.js";
15
+ import { buildActionPack, formatExplain, signActionPack, verifyActionPack } from "./explain.js";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
18
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -60,6 +61,13 @@ const USAGE = `agent-custody-memory <command>
60
61
  holds, the forget certificate and what the stores answered. For counsel and auditors.
61
62
  pack --verify <pack.json> --key <pub> [--issuer-key <pub>] [--principal-key <pub>]
62
63
  checks the pack's signature and every receipt inside it
64
+ explain --receipts <dir> --receipt <receiptId> [--ledger <ledger>] [--issuer-key <pub>] [--principal-key <pub>] [--log-key <pub>] [--json]
65
+ one action, answered: who, who authorized it, what was allowed, what the agent saw, what it did,
66
+ why, the evidence, whether it verifies, what depended on it, what needs reversal.
67
+ With a ledger the last two are answered from the beliefs; without, they say so.
68
+ explain ... --out <action.json> --sign <key> the same as one signed action pack, every downstream receipt inside
69
+ explain --verify <action.json> --key <pub> [--issuer-key <pub>] [--principal-key <pub>] [--log-key <pub>]
70
+ checks the pack, the receipt inside it, and every downstream receipt
63
71
  export --ledger <ledger.sqlite|postgres://…> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line; also the feed for a warehouse
64
72
  blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
65
73
  everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
@@ -189,6 +197,42 @@ async function main(argv) {
189
197
  console.error(`signed pack written to ${values.out}`);
190
198
  return pack.missingReceipts.length ? 1 : 0;
191
199
  }
200
+ case "explain": {
201
+ const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, receipt: { type: "string" }, out: { type: "string" }, sign: { type: "string" }, verify: { type: "string" }, key: { type: "string", multiple: true }, "issuer-key": { type: "string", multiple: true }, "principal-key": { type: "string", multiple: true }, "log-key": { type: "string", multiple: true }, json: { type: "boolean", default: false } } });
202
+ const receiptKeys = values["issuer-key"]?.length ? { issuerKeys: values["issuer-key"].map(loadPublicKey), principalKeys: (values["principal-key"] ?? []).map(loadPublicKey), ...(values["log-key"]?.length ? { logKeys: values["log-key"].map(loadPublicKey) } : {}) } : undefined;
203
+ if (values.verify) {
204
+ if (!values.key?.length)
205
+ throw new Error("explain --verify needs --key <pub>");
206
+ const r = verifyActionPack(JSON.parse(readFileSync(values.verify, "utf8")), values.key.map(loadPublicKey), receiptKeys);
207
+ if (values.json)
208
+ console.log(JSON.stringify(r, null, 2));
209
+ else {
210
+ for (const c of r.checks)
211
+ console.log(`${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
212
+ console.log(`\nRESULT: ${r.ok ? "VERIFIED" : "NOT VERIFIED"}`);
213
+ if (r.pack)
214
+ console.log("\n" + formatExplain(r.pack, r.receipt, true));
215
+ }
216
+ return r.ok ? 0 : 1;
217
+ }
218
+ if (!values.receipts || !values.receipt)
219
+ throw new Error("explain needs --receipts and --receipt, or --verify");
220
+ const ledger = values.ledger ? new Ledger(values.ledger) : undefined;
221
+ const pack = await buildActionPack(values.receipts, values.receipt, ledger);
222
+ await ledger?.close();
223
+ const verification = receiptKeys ? verifyBundle(pack.receipt, receiptKeys) : null;
224
+ if (values.out) {
225
+ if (!values.sign)
226
+ throw new Error("explain --out needs --sign <key>");
227
+ writeFileSync(values.out, JSON.stringify(signActionPack(pack, loadPrivateKey(values.sign)), null, 2));
228
+ console.error(`signed action pack written to ${values.out}`);
229
+ }
230
+ if (values.json)
231
+ console.log(JSON.stringify({ pack, verification }, null, 2));
232
+ else
233
+ console.log(formatExplain(pack, verification, !!ledger));
234
+ return verification && !verification.ok ? 1 : pack.missingReceipts.length ? 1 : 0;
235
+ }
192
236
  case "export": {
193
237
  const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, out: { type: "string" } } });
194
238
  if (!values.ledger || !values.out)
@@ -0,0 +1,53 @@
1
+ import { type Envelope, type KeyPair, type PublicKeyRef, type ReceiptBundle, type ReceiptStatement, type VerifyOptions, type VerifyResult } from "@agent-custody/receipts";
2
+ import { type ReceiptSummary } from "./blast.ts";
3
+ import type { Fact, Ledger } from "./ledger.ts";
4
+ export declare const ACTION_PACK_TYPE = "https://agent-custody.dev/action-pack/v0.1";
5
+ export interface ActionPack {
6
+ version: "0.1";
7
+ generatedAt: string;
8
+ receiptId: string;
9
+ /** the receipt itself, as issued */
10
+ receipt: ReceiptBundle;
11
+ /** the beliefs the agent had been shown before this call, as the ledger knows them; ids the ledger never held are listed apart */
12
+ consumed: {
13
+ facts: Fact[];
14
+ unknown: string[];
15
+ };
16
+ /** beliefs written in this call: every fact whose source receipt is this one */
17
+ written: Fact[];
18
+ /** what each written belief is today */
19
+ status: Record<string, "believed" | "retracted" | "forgotten" | "superseded">;
20
+ /** everything downstream of the written beliefs: later calls that consumed them and beliefs derived, transitively */
21
+ downstream: {
22
+ receipts: ReceiptSummary[];
23
+ derivedFacts: Fact[];
24
+ stillBelieved: Fact[];
25
+ };
26
+ /** every receipt cited by the downstream walk, by id, as the full bundle */
27
+ receipts: Record<string, ReceiptBundle>;
28
+ missingReceipts: string[];
29
+ }
30
+ export declare const decodeStatement: (b: ReceiptBundle) => ReceiptStatement;
31
+ /**
32
+ * Builds the pack. Without a ledger the receipt is explained on its own and the belief questions are answered as
33
+ * unknown rather than as nothing, so a reader can tell "nothing depended on it" from "nobody looked".
34
+ */
35
+ export declare function buildActionPack(receiptsDir: string, receiptId: string, ledger?: Ledger): Promise<ActionPack>;
36
+ export declare function signActionPack(pack: ActionPack, key: KeyPair): Envelope;
37
+ export interface ActionPackCheck {
38
+ name: string;
39
+ ok: boolean;
40
+ detail?: string;
41
+ }
42
+ export interface ActionPackVerification {
43
+ ok: boolean;
44
+ checks: ActionPackCheck[];
45
+ pack: ActionPack | null;
46
+ /** the receipt's own verification, when issuer keys were given */
47
+ receipt: VerifyResult | null;
48
+ }
49
+ export type ReceiptKeys = Pick<VerifyOptions, "issuerKeys" | "principalKeys" | "logKeys" | "upstreamKeys" | "providerSecrets">;
50
+ /** The pack's signature and digest, the receipt inside it against the gateway's keys, every downstream receipt likewise, and that the written beliefs really cite this receipt. */
51
+ export declare function verifyActionPack(envelope: Envelope, packKeys: PublicKeyRef[], keys?: ReceiptKeys): ActionPackVerification;
52
+ /** The ten questions, answered from the pack; `verification` is the receipt's own check list when keys were given. */
53
+ export declare function formatExplain(pack: ActionPack, verification: VerifyResult | null, withLedger: boolean): string;
@@ -0,0 +1,186 @@
1
+ // Explain one action. A security owner types a receipt id and gets the questions answered in the order they ask
2
+ // them: who acted, who authorized it, what was allowed, what the agent saw, what it did, why, what the evidence is,
3
+ // whether it verifies, what depended on it, and what needs reversing. The receipt answers the first eight on its own;
4
+ // the ledger answers the last two, because it knows which beliefs the call wrote and what was built on them since.
5
+ // The action pack is the same thing as one signed artefact, with every receipt it cites inside it.
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { digestOf, dsseSign, dsseVerify, verifyBundle } from "@agent-custody/receipts";
9
+ import { blastRadius, loadReceipts } from "./blast.js";
10
+ export const ACTION_PACK_TYPE = "https://agent-custody.dev/action-pack/v0.1";
11
+ export const decodeStatement = (b) => JSON.parse(Buffer.from(b.envelope.payload, "base64").toString());
12
+ function loadBundle(receiptsDir, receiptId) {
13
+ return JSON.parse(readFileSync(join(receiptsDir, `${receiptId}.json`), "utf8"));
14
+ }
15
+ /**
16
+ * Builds the pack. Without a ledger the receipt is explained on its own and the belief questions are answered as
17
+ * unknown rather than as nothing, so a reader can tell "nothing depended on it" from "nobody looked".
18
+ */
19
+ export async function buildActionPack(receiptsDir, receiptId, ledger) {
20
+ const receipt = loadBundle(receiptsDir, receiptId);
21
+ const p = decodeStatement(receipt).predicate;
22
+ const consumedIds = p.consumed?.factIds ?? [];
23
+ const consumed = { facts: [], unknown: [] };
24
+ const written = [];
25
+ const status = {};
26
+ const downstream = { receipts: [], derivedFacts: [], stillBelieved: [] };
27
+ const receipts = {};
28
+ const missingReceipts = [];
29
+ if (ledger) {
30
+ for (const id of consumedIds) {
31
+ const f = await ledger.get(id);
32
+ if (f)
33
+ consumed.facts.push(f);
34
+ else
35
+ consumed.unknown.push(id);
36
+ }
37
+ for (const f of await ledger.facts())
38
+ if (f.source.receiptId === receiptId)
39
+ written.push(f);
40
+ const summaries = loadReceipts(receiptsDir);
41
+ const seenReceipts = new Map();
42
+ const seenFacts = new Map();
43
+ const believed = new Set();
44
+ for (const f of written) {
45
+ const history = await ledger.history(f.factId);
46
+ status[f.factId] = history.some((e) => e.kind === "forget") ? "forgotten" : history.some((e) => e.kind === "retract") ? "retracted" : f.validTo !== null ? "superseded" : "believed";
47
+ const b = await blastRadius(ledger, summaries, f.factId);
48
+ for (const r of b.receipts)
49
+ seenReceipts.set(r.receiptId, r);
50
+ for (const d of b.derivedFacts)
51
+ seenFacts.set(d.factId, d);
52
+ for (const s of b.stillBelieved)
53
+ believed.add(s.factId);
54
+ }
55
+ downstream.receipts = [...seenReceipts.values()].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
56
+ downstream.derivedFacts = [...seenFacts.values()];
57
+ downstream.stillBelieved = downstream.derivedFacts.filter((f) => believed.has(f.factId));
58
+ const wanted = new Set([...downstream.receipts.map((r) => r.receiptId), ...downstream.derivedFacts.map((f) => f.source.receiptId).filter((x) => !!x)]);
59
+ for (const id of [...wanted].sort()) {
60
+ try {
61
+ receipts[id] = loadBundle(receiptsDir, id);
62
+ }
63
+ catch {
64
+ missingReceipts.push(id);
65
+ }
66
+ }
67
+ }
68
+ else {
69
+ consumed.unknown.push(...consumedIds);
70
+ }
71
+ return { version: "0.1", generatedAt: new Date().toISOString(), receiptId, receipt, consumed, written, status, downstream, receipts, missingReceipts };
72
+ }
73
+ export function signActionPack(pack, key) {
74
+ const statement = { _type: "https://in-toto.io/Statement/v1", subject: [{ name: `action-pack:${pack.receiptId}`, digest: { sha256: digestOf(pack) } }], predicateType: ACTION_PACK_TYPE, predicate: pack };
75
+ return dsseSign("application/vnd.in-toto+json", statement, key);
76
+ }
77
+ /** The pack's signature and digest, the receipt inside it against the gateway's keys, every downstream receipt likewise, and that the written beliefs really cite this receipt. */
78
+ export function verifyActionPack(envelope, packKeys, keys) {
79
+ const checks = [];
80
+ const add = (name, ok, detail) => {
81
+ checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
82
+ return ok;
83
+ };
84
+ const v = dsseVerify(envelope, packKeys);
85
+ if (!v.ok) {
86
+ add("pack signature", false, v.error);
87
+ return { ok: false, checks, pack: null, receipt: null };
88
+ }
89
+ add("pack signature", true, `keyid ${v.keyid.slice(0, 12)}`);
90
+ const st = v.payload;
91
+ if (!add("pack type", st.predicateType === ACTION_PACK_TYPE && !!st.predicate))
92
+ return { ok: false, checks, pack: null, receipt: null };
93
+ const pack = st.predicate;
94
+ add("pack digest", st.subject?.[0]?.digest?.sha256 === digestOf(pack));
95
+ let receiptResult = null;
96
+ const p = decodeStatement(pack.receipt).predicate;
97
+ add("receipt is the one the pack names", p.receiptId === pack.receiptId);
98
+ if (keys) {
99
+ const opts = { issuerKeys: keys.issuerKeys, principalKeys: keys.principalKeys ?? [], ...(keys.logKeys ? { logKeys: keys.logKeys } : {}), ...(keys.upstreamKeys ? { upstreamKeys: keys.upstreamKeys } : {}), ...(keys.providerSecrets ? { providerSecrets: keys.providerSecrets } : {}) };
100
+ receiptResult = verifyBundle(pack.receipt, opts);
101
+ add("receipt verifies", receiptResult.ok, receiptResult.ok ? `${receiptResult.checks.length} checks` : receiptResult.checks.filter((c) => !c.ok).map((c) => c.name).join(", "));
102
+ for (const [id, bundle] of Object.entries(pack.receipts)) {
103
+ const r = verifyBundle(bundle, opts);
104
+ add(`downstream receipt ${id.slice(0, 8)} verifies`, r.ok && r.statement?.predicate.receiptId === id, r.ok ? undefined : r.checks.filter((c) => !c.ok).map((c) => c.name).join(", "));
105
+ }
106
+ }
107
+ else {
108
+ add("receipts checked against the gateway's keys", false, "no issuer key given; pass --issuer-key to check the receipts inside the pack");
109
+ }
110
+ add("written beliefs cite this receipt", pack.written.every((f) => f.source.receiptId === pack.receiptId));
111
+ add("no downstream receipts missing", pack.missingReceipts.length === 0, pack.missingReceipts.length ? `${pack.missingReceipts.length} cited receipt(s) absent` : undefined);
112
+ return { ok: checks.every((c) => c.ok), checks, pack, receipt: receiptResult };
113
+ }
114
+ const decodeDelegation = (env) => {
115
+ try {
116
+ return JSON.parse(Buffer.from(env.payload, "base64").toString());
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ };
122
+ const factLine = (f) => `${f.subject} ${f.predicate}${f.forgotten ? " (erased)" : ` = ${JSON.stringify(f.value)}`} [${f.factId.slice(0, 8)}]`;
123
+ /** The ten questions, answered from the pack; `verification` is the receipt's own check list when keys were given. */
124
+ export function formatExplain(pack, verification, withLedger) {
125
+ const p = decodeStatement(pack.receipt).predicate;
126
+ const lines = [];
127
+ const row = (q, a) => lines.push(`${q.padEnd(28)} ${a}`);
128
+ const more = (a) => lines.push(`${"".padEnd(28)} ${a}`);
129
+ row("WHO", `${p.agent.id} (${p.agent.provenance}${p.issuer.kind === "sdk" ? ", self-reported by its own process" : ", named in a signed grant"})`);
130
+ const d = p.delegation ? decodeDelegation(p.delegation.envelope) : null;
131
+ if (p.principal.provenance === "attested")
132
+ row("WHO AUTHORIZED IT", `${p.principal.id}, grant signed by key ${p.principal.keyid.slice(0, 12)}${d ? `, valid ${d.issuedAt} to ${d.expiresAt}` : ""}`);
133
+ else
134
+ row("WHO AUTHORIZED IT", `${p.principal.id ?? "nobody named"} (claimed; no signed grant)`);
135
+ row("WHAT WAS ALLOWED", d?.scopes ? `tools ${d.scopes.join(", ")}` : "no grant in this receipt");
136
+ if (p.policy)
137
+ more(`policy ${p.policy.policyDigest.slice(0, 12)} decided ${p.policy.decision}${p.policy.reasons.length ? ` by ${p.policy.reasons.join(", ")}` : ""}${p.policy.errors.length ? `; errors: ${p.policy.errors.join("; ")}` : ""}`);
138
+ const facts = Object.entries(p.facts);
139
+ row("WHAT THE AGENT SAW", facts.length ? facts.map(([k, f]) => `${k} = ${JSON.stringify(f.value)} (fetched by the gateway via ${f.tool})`).join("; ") : "no facts fetched for this call");
140
+ const shown = p.consumed?.factIds ?? [];
141
+ if (!withLedger)
142
+ more(shown.length ? `${shown.length} belief(s) shown before this call; open with a ledger to see them` : "no beliefs shown before this call");
143
+ else if (shown.length === 0)
144
+ more("no beliefs shown before this call");
145
+ else {
146
+ more(`${shown.length} belief(s) shown before this call:`);
147
+ for (const f of pack.consumed.facts)
148
+ more(` ${factLine(f)}`);
149
+ for (const id of pack.consumed.unknown)
150
+ more(` ${id.slice(0, 8)} (not in this ledger)`);
151
+ }
152
+ row("WHAT IT DID", `${p.tool.name} ${JSON.stringify(p.request.args)} -> ${p.execution.status}${p.execution.status === "denied" ? `: ${p.execution.reason}` : p.execution.status === "withheld" ? `: ${p.execution.reason}` : p.execution.status === "error" ? `: ${p.execution.error}` : ""}`);
153
+ row("WHY", p.policy ? (p.policy.decision === "allow" ? `the policy permitted it${p.policy.reasons.length ? ` (${p.policy.reasons.join(", ")})` : ""}` : `the policy refused it: ${[...p.policy.reasons, ...p.policy.errors].join("; ") || "no permit matched"}`) : "no policy was evaluated");
154
+ const th = pack.receipt.treeHead.signatures[0]?.keyid ?? "?";
155
+ row("WHAT EVIDENCE", `receipt ${p.receiptId}, leaf ${pack.receipt.inclusion.leafIndex} of a log whose head is signed by ${th.slice(0, 12)}`);
156
+ if (p.authorization)
157
+ more(`authorization committed as leaf ${p.authorization.inclusion.leafIndex}, before the call was forwarded`);
158
+ if ((p.execution.status === "executed" || p.execution.status === "failed") && p.execution.upstream)
159
+ more("the upstream's own signature over the result is embedded");
160
+ row("CAN I VERIFY IT", verification ? (verification.ok ? `VERIFIED, ${verification.checks.length} checks` : `NOT VERIFIED: ${verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ")}`) : "not checked here; pass the gateway's and principal's public keys");
161
+ if (!withLedger) {
162
+ row("DID ANYTHING DEPEND ON THIS", "unknown without a ledger");
163
+ row("WHAT NEEDS REVERSAL", "unknown without a ledger");
164
+ }
165
+ else {
166
+ const w = pack.written;
167
+ if (w.length === 0)
168
+ row("DID ANYTHING DEPEND ON THIS", "this call wrote no beliefs; nothing in the ledger descends from it");
169
+ else {
170
+ row("DID ANYTHING DEPEND ON THIS", `${w.length} belief(s) written in this call, ${pack.downstream.receipts.length} later call(s) made after seeing them, ${pack.downstream.derivedFacts.length} belief(s) derived`);
171
+ for (const f of w)
172
+ more(` wrote ${factLine(f)}: ${pack.status[f.factId]}`);
173
+ for (const r of pack.downstream.receipts)
174
+ more(` then ${r.timestamp} ${r.tool} ${r.status} (receipt ${r.receiptId.slice(0, 8)})`);
175
+ }
176
+ const open = [...w.filter((f) => pack.status[f.factId] === "believed"), ...pack.downstream.stillBelieved];
177
+ if (open.length === 0)
178
+ row("WHAT NEEDS REVERSAL", w.length === 0 ? "nothing" : "nothing still believed; every belief from this call and after it is retracted, forgotten, or superseded");
179
+ else {
180
+ row("WHAT NEEDS REVERSAL", `${open.length} belief(s) still believed${pack.downstream.receipts.length ? `, and ${pack.downstream.receipts.length} later call(s) to review` : ""}`);
181
+ for (const f of open)
182
+ more(` ${factLine(f)} (space ${f.space})`);
183
+ }
184
+ }
185
+ return lines.join("\n");
186
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export { factMetadata, factText, mem0Store, zepStore } from "./stores.ts";
13
13
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.ts";
14
14
  export { memoryHttpHandler, serveMemoryHttp } from "./http.ts";
15
15
  export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.ts";
16
+ export { ACTION_PACK_TYPE, buildActionPack, decodeStatement, formatExplain, signActionPack, verifyActionPack } from "./explain.ts";
17
+ export type { ActionPack, ActionPackCheck, ActionPackVerification, ReceiptKeys } from "./explain.ts";
16
18
  export type { CustodyPack, PackCheck, PackVerification } from "./pack.ts";
17
19
  export type { MemoryHttpOptions, RunningMemoryServer } from "./http.ts";
18
20
  export type { BlastRadius, ReceiptSummary } from "./blast.ts";
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export { factMetadata, factText, mem0Store, zepStore } from "./stores.js";
9
9
  export { blastRadius, formatBlastRadius, loadReceipts } from "./blast.js";
10
10
  export { memoryHttpHandler, serveMemoryHttp } from "./http.js";
11
11
  export { PACK_TYPE, buildPack, formatPack, signPack, verifyPack } from "./pack.js";
12
+ export { ACTION_PACK_TYPE, buildActionPack, decodeStatement, formatExplain, signActionPack, verifyActionPack } from "./explain.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/state",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Governed memory for AI agents: a fact ledger with provenance, valid time, rollback, lineage, certified forget, and a store that is a JSONL file, SQLite, or Postgres, 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.2.0",
36
+ "@agent-custody/receipts": "0.3.0",
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
38
  "zod": "^4.5.4"
39
39
  },