@agent-custody/state 0.1.9 → 0.2.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 +15 -11
- package/dist/blast.d.ts +1 -1
- package/dist/blast.js +4 -4
- package/dist/cli.js +20 -11
- package/dist/evals-ledger.js +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ledger.d.ts +36 -25
- package/dist/ledger.js +86 -69
- package/dist/pack.d.ts +1 -1
- package/dist/pack.js +3 -3
- package/dist/server.js +26 -24
- package/dist/storage.d.ts +104 -16
- package/dist/storage.js +249 -23
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -15,11 +15,12 @@ Published on npm as [`@agent-custody/state`](https://www.npmjs.com/package/@agen
|
|
|
15
15
|
```ts
|
|
16
16
|
import { Ledger } from "@agent-custody/state";
|
|
17
17
|
|
|
18
|
+
// a JSONL file, a .sqlite path, or "postgres://…" with the pg package installed; the ledger is asynchronous
|
|
18
19
|
const ledger = new Ledger("./state/ledger.jsonl");
|
|
19
|
-
const a = ledger.assert({ subject: "acct:42", predicate: "plan", value: "pro", space: "org", actor: "agent:support", source: { receiptId } });
|
|
20
|
-
ledger.assert({ subject: "acct:42", predicate: "plan", value: "enterprise", space: "org", actor: "agent:sales", supersedes: a.fact.factId });
|
|
21
|
-
ledger.retract({ factId: a.fact.factId, actor: "user:admin", reason: "poisoned by a tool result" });
|
|
22
|
-
ledger.asOf({ validAt: "2026-09-01T00:00:00Z", txAt: "2026-09-01T00:00:00Z" });
|
|
20
|
+
const a = await ledger.assert({ subject: "acct:42", predicate: "plan", value: "pro", space: "org", actor: "agent:support", source: { receiptId } });
|
|
21
|
+
await ledger.assert({ subject: "acct:42", predicate: "plan", value: "enterprise", space: "org", actor: "agent:sales", supersedes: a.fact.factId });
|
|
22
|
+
await ledger.retract({ factId: a.fact.factId, actor: "user:admin", reason: "poisoned by a tool result" });
|
|
23
|
+
await ledger.asOf({ validAt: "2026-09-01T00:00:00Z", txAt: "2026-09-01T00:00:00Z" });
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
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.
|
|
@@ -157,9 +158,13 @@ A scenario file is `{ "version": "0.1", "scenarios": [{ "name", "ops": [...] }]
|
|
|
157
158
|
|
|
158
159
|
## The ledger
|
|
159
160
|
|
|
160
|
-
`src/ledger.ts` keeps an append-only log of events
|
|
161
|
+
`src/ledger.ts` keeps an append-only log of events. It holds none of them itself: every question is a query to a store, so the store decides how large a ledger can be and who shares it. A store answers four questions, everything in order (export, audits), everything about the facts matching a filter (a bitemporal read), everything that touched one fact (its history and its checks), and which spaces exist (retention), and it does three things: append, replace one assert in place (forget), and compact after erasures. Three stores ship, and the whole ledger suite runs against each of them, so they answer identically.
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
**JSONL** is the default: one event per line, readable by anyone, the file you copy for an audit. It answers from memory, which is fine to tens of thousands of events. **SQLite**, chosen by a path ending in `.sqlite` or `.db`, is for durability on one machine: transactional writes with write-ahead logging, an in-place forget that overwrites the erased value (secure delete, then a truncating checkpoint so nothing lingers in the write-ahead log), and every query answered by an index on fact, time, space, subject, predicate, and supersession. Node ships the SQLite module, so there is no native dependency; a ledger written by an earlier version gets the index columns filled from its JSON the first time it is opened. Measured on a million-event ledger: open in 0.2 s, a subject read in about 1 ms, a fact's history in well under a millisecond, the spaces for a sweep in 2 ms. **Postgres**, chosen by a `postgres://` URL, is for a shared ledger: several memory servers on one table, in the database your security team has already approved, the same queries pushed down as SQL. It needs the `pg` package installed beside this one; `?table=custody.events` names the table and `?vacuum=false` skips the vacuum described below. In code, pass a `PostgresStore` around your own `pg` Pool, with whatever TLS and credentials you already use. The adapter is tested against the real Postgres engine in-process through PGlite, including that a forgotten value is absent from the database files, and checked by hand against Postgres 16.
|
|
164
|
+
|
|
165
|
+
Forget on Postgres is an `UPDATE`, and MVCC keeps the old row image in the table until vacuum. So after a forget or a sweep the store runs `VACUUM FULL` on the table, which rewrites it without the old image. That takes an exclusive lock for the rewrite; a very large ledger may set `vacuum: false` and run its own schedule, and the forget certificate then rests on that schedule. The write-ahead log, replicas, and backups keep their own copies for as long as their retention says; that is true of every database, and the honest reading of a forget certificate is that the live ledger no longer holds the value.
|
|
166
|
+
|
|
167
|
+
Choose JSONL for one server process and for anything an auditor should read with `cat`. Choose SQLite when one machine serves the ledger over HTTP, when a crash between two writes must not cost you an event, or when a deletion demand must leave no trace of the value in the file. Choose Postgres when more than one server must share the ledger, or when the ledger belongs in the database you already operate. Warehouses are not stores: Snowflake and Databricks keep deleted rows for days by default and are built for scans, not one small write per agent call. Feed them with `agent-custody-memory export --ledger postgres://… --out ledger.jsonl`, which writes the auditable JSONL from any store, and query the ledger where your compliance team already works.
|
|
163
168
|
|
|
164
169
|
The log holds these kinds of event.
|
|
165
170
|
|
|
@@ -181,7 +186,7 @@ The ledger refuses to supersede a fact that is unknown, already superseded, or r
|
|
|
181
186
|
|
|
182
187
|
```
|
|
183
188
|
src/ledger.ts the fact record, the event kinds, as-of queries, supersession, retraction, forget, holds, sweeps
|
|
184
|
-
src/storage.ts the
|
|
189
|
+
src/storage.ts the store interface and its three stores: JSONL (default, auditable), SQLite (indexed, one machine), Postgres (shared); chosen by path or URL
|
|
185
190
|
src/server.ts the ledger as MCP tools; source and actor taken from the gateway's _meta
|
|
186
191
|
src/http.ts the memory server over Streamable HTTP with bearer auth, for a shared ledger
|
|
187
192
|
src/pack.ts the custody pack: build, sign, verify, format
|
|
@@ -201,7 +206,7 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
201
206
|
|
|
202
207
|
**Done**
|
|
203
208
|
|
|
204
|
-
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL or
|
|
209
|
+
- Bitemporal fact ledger with supersession, retraction, as-of and history queries, persisted as JSONL, SQLite, or Postgres behind one store interface that answers every query from an index, with export back to JSONL.
|
|
205
210
|
- 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.
|
|
206
211
|
- Forget digests are keyed under a server-held secret, or absent on request, so an erased value cannot be guessed back from the file.
|
|
207
212
|
- 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.
|
|
@@ -221,7 +226,6 @@ tsconfig.build.json emits dist/ for consumers; the repo itself runs the .ts dir
|
|
|
221
226
|
|
|
222
227
|
**Next, in the order it pays off**
|
|
223
228
|
|
|
224
|
-
1.
|
|
225
|
-
2.
|
|
226
|
-
3. The hosted plane, behind early access: tenanted log, then memory, then reports and a control plane, with SSO, SCIM, residency, and SIEM export. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
|
|
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).
|
|
227
231
|
|
package/dist/blast.d.ts
CHANGED
|
@@ -21,5 +21,5 @@ export interface BlastRadius {
|
|
|
21
21
|
/** derived facts that are still believed; the ones a cleanup has to decide about */
|
|
22
22
|
stillBelieved: Fact[];
|
|
23
23
|
}
|
|
24
|
-
export declare function blastRadius(ledger: Ledger, receipts: ReceiptSummary[], factId: string): BlastRadius
|
|
24
|
+
export declare function blastRadius(ledger: Ledger, receipts: ReceiptSummary[], factId: string): Promise<BlastRadius>;
|
|
25
25
|
export declare function formatBlastRadius(b: BlastRadius, factId: string): string;
|
package/dist/blast.js
CHANGED
|
@@ -20,10 +20,10 @@ export function loadReceipts(dir) {
|
|
|
20
20
|
}
|
|
21
21
|
return out.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
22
22
|
}
|
|
23
|
-
export function blastRadius(ledger, receipts, factId) {
|
|
24
|
-
const all = ledger.facts();
|
|
23
|
+
export async function blastRadius(ledger, receipts, factId) {
|
|
24
|
+
const all = await ledger.facts();
|
|
25
25
|
const byId = new Map(all.map((f) => [f.factId, f]));
|
|
26
|
-
const believed = new Set(ledger.asOf().map((f) => f.factId));
|
|
26
|
+
const believed = new Set((await ledger.asOf()).map((f) => f.factId));
|
|
27
27
|
const seen = new Set([factId]);
|
|
28
28
|
const hit = new Map();
|
|
29
29
|
const derived = new Map();
|
|
@@ -44,7 +44,7 @@ export function blastRadius(ledger, receipts, factId) {
|
|
|
44
44
|
grew = true;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
const retraction = ledger.history(factId).find((e) => e.kind === "retract") ?? null;
|
|
47
|
+
const retraction = (await ledger.history(factId)).find((e) => e.kind === "retract") ?? null;
|
|
48
48
|
const derivedFacts = [...derived.values()];
|
|
49
49
|
return { fact: byId.get(factId) ?? null, receipts: [...hit.values()].sort((a, b) => a.timestamp.localeCompare(b.timestamp)), derivedFacts, retraction, stillBelieved: derivedFacts.filter((f) => believed.has(f.factId)) };
|
|
50
50
|
}
|
package/dist/cli.js
CHANGED
|
@@ -37,13 +37,15 @@ function parseRetention(spec) {
|
|
|
37
37
|
}
|
|
38
38
|
const USAGE = `agent-custody-memory <command>
|
|
39
39
|
|
|
40
|
-
serve --ledger <ledger.jsonl|ledger.sqlite
|
|
40
|
+
serve --ledger <ledger.jsonl|ledger.sqlite|postgres://…> [--allow-direct] [--key <memory.key>] [--forget-key-env NAME] [--retention 'org=P365D,team:*=P90D']
|
|
41
41
|
--forget-key-env names a secret kept outside the ledger; forgotten values then leave an HMAC, not a guessable hash.
|
|
42
42
|
the memory server over stdio; run it as the receipts gateway's upstream.
|
|
43
43
|
--key signs every result for its receipt, so executions verify as attested by this server.
|
|
44
44
|
By default it refuses calls that did not come through the gateway.
|
|
45
45
|
serve --ledger <ledger.jsonl> --http [--port 8790] [--host 127.0.0.1] [--token-env NAME] [--allow-direct]
|
|
46
|
-
the same server shared over HTTP: several gateways, one ledger
|
|
46
|
+
the same server shared over HTTP: several gateways, one ledger.
|
|
47
|
+
A postgres:// ledger (needs the pg package; ?table=custody.events names the table)
|
|
48
|
+
is shared by every server pointed at it.
|
|
47
49
|
sweep --via <gateway.json> --reason <text> [--before <ISO instant>] [--space <space>] [--no-digest]
|
|
48
50
|
retention as a receipted call: runs memory.sweep through that gateway, as the principal in its grant.
|
|
49
51
|
Without --before, the memory server's --retention windows decide. Put this on a timer.
|
|
@@ -58,7 +60,7 @@ const USAGE = `agent-custody-memory <command>
|
|
|
58
60
|
holds, the forget certificate and what the stores answered. For counsel and auditors.
|
|
59
61
|
pack --verify <pack.json> --key <pub> [--issuer-key <pub>] [--principal-key <pub>]
|
|
60
62
|
checks the pack's signature and every receipt inside it
|
|
61
|
-
export --ledger <ledger.sqlite
|
|
63
|
+
export --ledger <ledger.sqlite|postgres://…> --out <ledger.jsonl> the auditable JSONL of any ledger, one event per line; also the feed for a warehouse
|
|
62
64
|
blast --ledger <ledger.jsonl> --receipts <dir> --fact <factId> [--json]
|
|
63
65
|
everything that relied on a fact: later calls, derived beliefs, and whether it was retracted
|
|
64
66
|
`;
|
|
@@ -80,14 +82,14 @@ async function main(argv) {
|
|
|
80
82
|
if (values["token-env"] && !token)
|
|
81
83
|
throw new Error(`serve: environment variable ${values["token-env"]} is not set`);
|
|
82
84
|
const running = await serveMemoryHttp(ledger, { port: Number(values.port), host: values.host, ...common, ...(token ? { tokens: [token] } : {}) });
|
|
83
|
-
console.error(`agent-custody-memory: ${running.url} ledger=${values.ledger} events=${ledger.
|
|
85
|
+
console.error(`agent-custody-memory: ${running.url} ledger=${values.ledger} events=${await ledger.count()} ${token ? "bearer token required" : "open"} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
|
|
84
86
|
if (digestWarning)
|
|
85
87
|
console.error(digestWarning);
|
|
86
88
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
87
89
|
await running.close();
|
|
88
90
|
return 0;
|
|
89
91
|
}
|
|
90
|
-
console.error(`agent-custody-memory: ledger=${values.ledger} events=${ledger.
|
|
92
|
+
console.error(`agent-custody-memory: ledger=${values.ledger} events=${await ledger.count()} ${values["allow-direct"] ? "direct calls allowed" : "gateway calls only"}`);
|
|
91
93
|
if (digestWarning)
|
|
92
94
|
console.error(digestWarning);
|
|
93
95
|
await serveStdio(createMemoryServer(ledger, common));
|
|
@@ -115,10 +117,12 @@ async function main(argv) {
|
|
|
115
117
|
}
|
|
116
118
|
if (!values.ledger || !values.before || !values.reason)
|
|
117
119
|
throw new Error("sweep needs --ledger, --before, and --reason, or --via <gateway.json> --reason");
|
|
118
|
-
const
|
|
120
|
+
const sweeper = new Ledger(values.ledger, forgetKeyFrom(values["forget-key-env"]));
|
|
121
|
+
const r = await sweeper.sweep({ before: new Date(values.before).toISOString(), ...(values.space ? { space: values.space } : {}), actor: values.actor, reason: values.reason, ...(values["no-digest"] ? { keepDigest: false } : {}) });
|
|
119
122
|
console.log(`forgot ${r.forgotten.length} fact(s); ${r.held.length} on hold, kept`);
|
|
120
123
|
for (const f of r.forgotten)
|
|
121
124
|
console.log(` ${f.factId} ${f.digestKind}${f.valueDigest ? ` ${f.valueDigest.slice(0, 12)}` : ""}`);
|
|
125
|
+
await sweeper.close();
|
|
122
126
|
return 0;
|
|
123
127
|
}
|
|
124
128
|
case "eval": {
|
|
@@ -177,7 +181,9 @@ async function main(argv) {
|
|
|
177
181
|
}
|
|
178
182
|
if (!values.ledger || !values.receipts || !values.fact || !values.out || !values.sign)
|
|
179
183
|
throw new Error("pack needs --ledger, --receipts, --fact, --out, and --sign");
|
|
180
|
-
const
|
|
184
|
+
const packLedger = new Ledger(values.ledger);
|
|
185
|
+
const pack = await buildPack(packLedger, values.receipts, values.fact);
|
|
186
|
+
await packLedger.close();
|
|
181
187
|
writeFileSync(values.out, JSON.stringify(signPack(pack, loadPrivateKey(values.sign)), null, 2));
|
|
182
188
|
console.log(formatPack(pack));
|
|
183
189
|
console.error(`signed pack written to ${values.out}`);
|
|
@@ -188,16 +194,19 @@ async function main(argv) {
|
|
|
188
194
|
if (!values.ledger || !values.out)
|
|
189
195
|
throw new Error("export needs --ledger and --out");
|
|
190
196
|
const l = new Ledger(values.ledger);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
const events = await l.export();
|
|
198
|
+
writeFileSync(values.out, events.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
199
|
+
console.log(`exported ${events.length} event(s) from ${l.location} to ${values.out}`);
|
|
200
|
+
await l.close();
|
|
194
201
|
return 0;
|
|
195
202
|
}
|
|
196
203
|
case "blast": {
|
|
197
204
|
const { values } = parseArgs({ args: rest, options: { ledger: { type: "string" }, receipts: { type: "string" }, fact: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
198
205
|
if (!values.ledger || !values.receipts || !values.fact)
|
|
199
206
|
throw new Error("blast needs --ledger, --receipts, and --fact");
|
|
200
|
-
const
|
|
207
|
+
const blastLedger = new Ledger(values.ledger);
|
|
208
|
+
const b = await blastRadius(blastLedger, loadReceipts(values.receipts), values.fact);
|
|
209
|
+
await blastLedger.close();
|
|
201
210
|
console.log(values.json ? JSON.stringify(b, null, 2) : formatBlastRadius(b, values.fact));
|
|
202
211
|
return b.fact ? 0 : 1;
|
|
203
212
|
}
|
package/dist/evals-ledger.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export function ledgerUnderTest(ledger) {
|
|
2
2
|
return {
|
|
3
|
-
write: (i) => ({ id: ledger.assert({ ...i, provenance: "attested" }).fact.factId }),
|
|
4
|
-
read: (q) => ledger.asOf({ ...q, include: "attested" }).map(({ subject, predicate, value }) => ({ subject, predicate, value })),
|
|
5
|
-
retract: (i) => {
|
|
6
|
-
ledger.retract({ factId: i.id, actor: i.actor, reason: i.reason });
|
|
3
|
+
write: async (i) => ({ id: (await ledger.assert({ ...i, provenance: "attested" })).fact.factId }),
|
|
4
|
+
read: async (q) => (await ledger.asOf({ ...q, include: "attested" })).map(({ subject, predicate, value }) => ({ subject, predicate, value })),
|
|
5
|
+
retract: async (i) => {
|
|
6
|
+
await ledger.retract({ factId: i.id, actor: i.actor, reason: i.reason });
|
|
7
7
|
},
|
|
8
8
|
};
|
|
9
9
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { Ledger } from "./ledger.ts";
|
|
2
|
-
export { JsonlStore, SqliteStore, openStore } from "./storage.ts";
|
|
3
|
-
export type { EventStore } from "./storage.ts";
|
|
2
|
+
export { JsonlStore, PostgresStore, SqliteStore, openStore, selectAbout, selectFor } from "./storage.ts";
|
|
3
|
+
export type { EventQuery, EventStore, PostgresLike, PostgresOptions } from "./storage.ts";
|
|
4
4
|
export type { AsOf, AssertEvent, AssertInput, ConfirmEvent, ConfirmInput, Fact, FactProvenance, DigestKind, ForgetEvent, ForgetInput, HoldEvent, HoldInput, LedgerEvent, SweepInput, RetractEvent, RetractInput, Source } from "./ledger.ts";
|
|
5
5
|
export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.ts";
|
|
6
6
|
export type { MemoryServerOptions } from "./server.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 { JsonlStore, SqliteStore, openStore } from "./storage.js";
|
|
3
|
+
export { JsonlStore, PostgresStore, SqliteStore, openStore, selectAbout, selectFor } from "./storage.js";
|
|
4
4
|
export { AGENT_META_KEY, FACTS_META_KEY, OBSERVED_META_KEY, RECEIPT_META_KEY, SERVER_VERSION, TOOLS, createMemoryServer, durationMs, retentionCutoff, serveStdio } from "./server.js";
|
|
5
5
|
export { SCENARIOS, formatReport, runAll, runScenario } from "./evals.js";
|
|
6
6
|
export { ledgerUnderTest, overwriteStoreUnderTest } from "./evals-ledger.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -155,13 +155,14 @@ export interface AsOf {
|
|
|
155
155
|
include?: "attested" | "verified" | "all";
|
|
156
156
|
}
|
|
157
157
|
export declare class Ledger {
|
|
158
|
-
private readonly events;
|
|
159
158
|
private readonly store;
|
|
160
159
|
private readonly now;
|
|
161
160
|
private readonly forgetKey;
|
|
162
161
|
/**
|
|
163
|
-
* `location` is a path
|
|
164
|
-
*
|
|
162
|
+
* `location` is a path (JSONL by default, SQLite when it ends in .sqlite or .db) or a postgres:// URL; or pass a
|
|
163
|
+
* store. The ledger holds no events itself: every question is a query to the store, so a shared store means a
|
|
164
|
+
* shared ledger. forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a
|
|
165
|
+
* plain hash.
|
|
165
166
|
*/
|
|
166
167
|
constructor(location: string | EventStore, opts?: {
|
|
167
168
|
now?: () => Date;
|
|
@@ -170,37 +171,47 @@ export declare class Ledger {
|
|
|
170
171
|
/** Where the events live, for reports. */
|
|
171
172
|
get location(): string;
|
|
172
173
|
/** Every event in order, for export. */
|
|
173
|
-
export(): LedgerEvent[]
|
|
174
|
-
close(): void
|
|
175
|
-
|
|
174
|
+
export(): Promise<LedgerEvent[]>;
|
|
175
|
+
close(): Promise<void>;
|
|
176
|
+
count(): Promise<number>;
|
|
177
|
+
/** Every space with at least one fact. */
|
|
178
|
+
spaces(): Promise<string[]>;
|
|
176
179
|
/** The checks assert makes, without appending. For callers that must do something irreversible before the append. */
|
|
177
|
-
validateAssert(input: AssertInput): void
|
|
178
|
-
assert(input: AssertInput): AssertEvent
|
|
179
|
-
retract(input: RetractInput): RetractEvent
|
|
180
|
-
confirm(input: ConfirmInput): ConfirmEvent
|
|
181
|
-
/**
|
|
182
|
-
* Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
|
|
183
|
-
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
184
|
-
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
185
|
-
*/
|
|
180
|
+
validateAssert(input: AssertInput): Promise<void>;
|
|
181
|
+
assert(input: AssertInput): Promise<AssertEvent>;
|
|
182
|
+
retract(input: RetractInput): Promise<RetractEvent>;
|
|
183
|
+
confirm(input: ConfirmInput): Promise<ConfirmEvent>;
|
|
186
184
|
/** Whether a legal hold currently stands on the fact. */
|
|
187
|
-
held(factId: string): boolean
|
|
188
|
-
hold(input: HoldInput): HoldEvent
|
|
189
|
-
release(input: HoldInput): HoldEvent
|
|
185
|
+
held(factId: string): Promise<boolean>;
|
|
186
|
+
hold(input: HoldInput): Promise<HoldEvent>;
|
|
187
|
+
release(input: HoldInput): Promise<HoldEvent>;
|
|
188
|
+
/** The facts the ledger learned of before an instant, in one space or all, that have not been forgotten: what a retention sweep decides about. */
|
|
189
|
+
learnedBefore(before: string, space?: string): Promise<Fact[]>;
|
|
190
190
|
/** 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. */
|
|
191
|
-
sweep(input: SweepInput): {
|
|
191
|
+
sweep(input: SweepInput): Promise<{
|
|
192
192
|
forgotten: ForgetEvent[];
|
|
193
193
|
held: string[];
|
|
194
|
-
}
|
|
195
|
-
|
|
194
|
+
}>;
|
|
195
|
+
/**
|
|
196
|
+
* Erases a fact's value from the store itself, keeping its digest, and stops believing it. The store rewrites the
|
|
197
|
+
* event in place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about
|
|
198
|
+
* the fact stays: who wrote it, when, from which receipt, and now who erased it and why. With compact false the
|
|
199
|
+
* store's reclaim step is left to the caller, for a batch of forgets followed by one compact().
|
|
200
|
+
*/
|
|
201
|
+
forget(input: ForgetInput & {
|
|
202
|
+
compact?: boolean;
|
|
203
|
+
}): Promise<ForgetEvent>;
|
|
204
|
+
/** Reclaims whatever the store may still hold of erased values; forget and sweep do this themselves unless told not to. */
|
|
205
|
+
compact(): Promise<void>;
|
|
196
206
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
197
|
-
asOf(q?: AsOf): Fact[]
|
|
207
|
+
asOf(q?: AsOf): Promise<Fact[]>;
|
|
208
|
+
/** One fact by id, whatever its state, with supersession applied; undefined when the ledger never held it. */
|
|
209
|
+
get(factId: string): Promise<Fact | undefined>;
|
|
198
210
|
/** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
|
|
199
|
-
facts(): Fact[]
|
|
211
|
+
facts(): Promise<Fact[]>;
|
|
200
212
|
/** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
|
|
201
|
-
history(factId: string): LedgerEvent[]
|
|
213
|
+
history(factId: string): Promise<LedgerEvent[]>;
|
|
202
214
|
private confirmedAt;
|
|
203
215
|
private factById;
|
|
204
216
|
private retractedAt;
|
|
205
|
-
private append;
|
|
206
217
|
}
|
package/dist/ledger.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The fact ledger: an append-only
|
|
1
|
+
// The fact ledger: an append-only log of events about what an agent believes, in a JSONL file, SQLite, or Postgres.
|
|
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";
|
|
@@ -6,19 +6,19 @@ import { createHash, createHmac } from "node:crypto";
|
|
|
6
6
|
import { openStore } from "./storage.js";
|
|
7
7
|
const RANK = { claimed: 0, attested: 1, verified: 2 };
|
|
8
8
|
export class Ledger {
|
|
9
|
-
events;
|
|
10
9
|
store;
|
|
11
10
|
now;
|
|
12
11
|
forgetKey;
|
|
13
12
|
/**
|
|
14
|
-
* `location` is a path
|
|
15
|
-
*
|
|
13
|
+
* `location` is a path (JSONL by default, SQLite when it ends in .sqlite or .db) or a postgres:// URL; or pass a
|
|
14
|
+
* store. The ledger holds no events itself: every question is a query to the store, so a shared store means a
|
|
15
|
+
* shared ledger. forgetKey: a secret kept outside the store; with it, forgotten values leave an HMAC rather than a
|
|
16
|
+
* plain hash.
|
|
16
17
|
*/
|
|
17
18
|
constructor(location, opts = {}) {
|
|
18
19
|
this.store = typeof location === "string" ? openStore(location) : location;
|
|
19
20
|
this.now = opts.now ?? (() => new Date());
|
|
20
21
|
this.forgetKey = opts.forgetKey ? Buffer.from(opts.forgetKey) : null;
|
|
21
|
-
this.events = this.store.load();
|
|
22
22
|
}
|
|
23
23
|
/** Where the events live, for reports. */
|
|
24
24
|
get location() {
|
|
@@ -26,31 +26,35 @@ export class Ledger {
|
|
|
26
26
|
}
|
|
27
27
|
/** Every event in order, for export. */
|
|
28
28
|
export() {
|
|
29
|
-
return
|
|
29
|
+
return this.store.events();
|
|
30
30
|
}
|
|
31
31
|
close() {
|
|
32
|
-
this.store.close();
|
|
32
|
+
return this.store.close();
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
return this.
|
|
34
|
+
count() {
|
|
35
|
+
return this.store.count();
|
|
36
|
+
}
|
|
37
|
+
/** Every space with at least one fact. */
|
|
38
|
+
spaces() {
|
|
39
|
+
return this.store.spaces();
|
|
36
40
|
}
|
|
37
41
|
/** The checks assert makes, without appending. For callers that must do something irreversible before the append. */
|
|
38
|
-
validateAssert(input) {
|
|
42
|
+
async validateAssert(input) {
|
|
39
43
|
const validFrom = input.validFrom ?? this.now().toISOString();
|
|
40
44
|
if (input.supersedes !== undefined) {
|
|
41
|
-
const prior = this.factById(input.supersedes);
|
|
45
|
+
const prior = await this.factById(input.supersedes);
|
|
42
46
|
if (!prior)
|
|
43
47
|
throw new Error(`cannot supersede unknown fact ${input.supersedes}`);
|
|
44
48
|
if (prior.fact.validTo !== null)
|
|
45
49
|
throw new Error(`fact ${input.supersedes} is already superseded`);
|
|
46
|
-
if (this.retractedAt(input.supersedes))
|
|
50
|
+
if (await this.retractedAt(input.supersedes))
|
|
47
51
|
throw new Error(`fact ${input.supersedes} is retracted`);
|
|
48
52
|
if (validFrom < prior.fact.validFrom)
|
|
49
53
|
throw new Error(`replacement cannot start before the fact it supersedes`);
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
|
-
assert(input) {
|
|
53
|
-
this.validateAssert(input);
|
|
56
|
+
async assert(input) {
|
|
57
|
+
await this.validateAssert(input);
|
|
54
58
|
const txTime = this.now().toISOString();
|
|
55
59
|
const validFrom = input.validFrom ?? txTime;
|
|
56
60
|
const event = {
|
|
@@ -73,13 +77,13 @@ export class Ledger {
|
|
|
73
77
|
},
|
|
74
78
|
supersedes: input.supersedes ?? null,
|
|
75
79
|
};
|
|
76
|
-
this.append(event);
|
|
80
|
+
await this.store.append(event);
|
|
77
81
|
return event;
|
|
78
82
|
}
|
|
79
|
-
retract(input) {
|
|
80
|
-
if (!this.factById(input.factId))
|
|
83
|
+
async retract(input) {
|
|
84
|
+
if (!(await this.factById(input.factId)))
|
|
81
85
|
throw new Error(`cannot retract unknown fact ${input.factId}`);
|
|
82
|
-
if (this.retractedAt(input.factId))
|
|
86
|
+
if (await this.retractedAt(input.factId))
|
|
83
87
|
throw new Error(`fact ${input.factId} is already retracted`);
|
|
84
88
|
const event = {
|
|
85
89
|
eventId: randomUUID(),
|
|
@@ -90,95 +94,106 @@ export class Ledger {
|
|
|
90
94
|
reason: input.reason,
|
|
91
95
|
source: input.source ?? { receiptId: null },
|
|
92
96
|
};
|
|
93
|
-
this.append(event);
|
|
97
|
+
await this.store.append(event);
|
|
94
98
|
return event;
|
|
95
99
|
}
|
|
96
|
-
confirm(input) {
|
|
97
|
-
const prior = this.factById(input.factId);
|
|
100
|
+
async confirm(input) {
|
|
101
|
+
const prior = await this.factById(input.factId);
|
|
98
102
|
if (!prior)
|
|
99
103
|
throw new Error(`cannot confirm unknown fact ${input.factId}`);
|
|
100
|
-
if (this.retractedAt(input.factId))
|
|
104
|
+
if (await this.retractedAt(input.factId))
|
|
101
105
|
throw new Error(`fact ${input.factId} is retracted`);
|
|
102
|
-
if (prior.fact.provenance !== "claimed" || this.confirmedAt(input.factId))
|
|
106
|
+
if (prior.fact.provenance !== "claimed" || (await this.confirmedAt(input.factId)))
|
|
103
107
|
throw new Error(`fact ${input.factId} is already attested`);
|
|
104
108
|
const event = { eventId: randomUUID(), kind: "confirm", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, source: input.source ?? { receiptId: null } };
|
|
105
|
-
this.append(event);
|
|
109
|
+
await this.store.append(event);
|
|
106
110
|
return event;
|
|
107
111
|
}
|
|
108
|
-
/**
|
|
109
|
-
* Erases a fact's value from the ledger file, keeping its digest, and stops believing it. The file is rewritten in
|
|
110
|
-
* place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about the fact
|
|
111
|
-
* stays: who wrote it, when, from which receipt, and now who erased it and why.
|
|
112
|
-
*/
|
|
113
112
|
/** Whether a legal hold currently stands on the fact. */
|
|
114
|
-
held(factId) {
|
|
113
|
+
async held(factId) {
|
|
115
114
|
let held = false;
|
|
116
|
-
for (const e of this.
|
|
115
|
+
for (const e of await this.store.eventsFor(factId))
|
|
117
116
|
if ((e.kind === "hold" || e.kind === "release") && e.factId === factId)
|
|
118
117
|
held = e.kind === "hold";
|
|
119
118
|
return held;
|
|
120
119
|
}
|
|
121
|
-
hold(input) {
|
|
122
|
-
const prior = this.factById(input.factId);
|
|
120
|
+
async hold(input) {
|
|
121
|
+
const prior = await this.factById(input.factId);
|
|
123
122
|
if (!prior)
|
|
124
123
|
throw new Error(`cannot hold unknown fact ${input.factId}`);
|
|
125
124
|
if (prior.fact.forgotten)
|
|
126
125
|
throw new Error(`fact ${input.factId} is already forgotten`);
|
|
127
|
-
if (this.held(input.factId))
|
|
126
|
+
if (await this.held(input.factId))
|
|
128
127
|
throw new Error(`fact ${input.factId} is already on hold`);
|
|
129
128
|
const event = { eventId: randomUUID(), kind: "hold", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null } };
|
|
130
|
-
this.append(event);
|
|
129
|
+
await this.store.append(event);
|
|
131
130
|
return event;
|
|
132
131
|
}
|
|
133
|
-
release(input) {
|
|
134
|
-
if (!this.held(input.factId))
|
|
132
|
+
async release(input) {
|
|
133
|
+
if (!(await this.held(input.factId)))
|
|
135
134
|
throw new Error(`fact ${input.factId} is not on hold`);
|
|
136
135
|
const event = { eventId: randomUUID(), kind: "release", txTime: this.now().toISOString(), factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null } };
|
|
137
|
-
this.append(event);
|
|
136
|
+
await this.store.append(event);
|
|
138
137
|
return event;
|
|
139
138
|
}
|
|
139
|
+
/** The facts the ledger learned of before an instant, in one space or all, that have not been forgotten: what a retention sweep decides about. */
|
|
140
|
+
async learnedBefore(before, space) {
|
|
141
|
+
const events = await this.store.eventsAbout({ txBefore: before, ...(space === undefined ? {} : { space }) });
|
|
142
|
+
return events.filter((e) => e.kind === "assert" && e.txTime < before && (space === undefined || e.fact.space === space) && !e.fact.forgotten).map((e) => ({ ...e.fact }));
|
|
143
|
+
}
|
|
140
144
|
/** 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. */
|
|
141
|
-
sweep(input) {
|
|
145
|
+
async sweep(input) {
|
|
142
146
|
const forgotten = [];
|
|
143
147
|
const held = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
held.push(e.fact.factId);
|
|
148
|
+
for (const f of await this.learnedBefore(input.before, input.space)) {
|
|
149
|
+
if (await this.held(f.factId)) {
|
|
150
|
+
held.push(f.factId);
|
|
148
151
|
continue;
|
|
149
152
|
}
|
|
150
|
-
forgotten.push(this.forget({ factId:
|
|
153
|
+
forgotten.push(await this.forget({ factId: f.factId, actor: input.actor, reason: input.reason, ...(input.source ? { source: input.source } : {}), ...(input.keepDigest === undefined ? {} : { keepDigest: input.keepDigest }), compact: false }));
|
|
151
154
|
}
|
|
155
|
+
if (forgotten.length > 0)
|
|
156
|
+
await this.store.compact();
|
|
152
157
|
return { forgotten, held };
|
|
153
158
|
}
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Erases a fact's value from the store itself, keeping its digest, and stops believing it. The store rewrites the
|
|
161
|
+
* event in place, which is the one thing an append-only ledger must do for a deletion demand. Everything else about
|
|
162
|
+
* the fact stays: who wrote it, when, from which receipt, and now who erased it and why. With compact false the
|
|
163
|
+
* store's reclaim step is left to the caller, for a batch of forgets followed by one compact().
|
|
164
|
+
*/
|
|
165
|
+
async forget(input) {
|
|
166
|
+
const prior = await this.factById(input.factId);
|
|
156
167
|
if (!prior)
|
|
157
168
|
throw new Error(`cannot forget unknown fact ${input.factId}`);
|
|
158
169
|
if (prior.fact.forgotten)
|
|
159
170
|
throw new Error(`fact ${input.factId} is already forgotten`);
|
|
160
|
-
if (this.held(input.factId))
|
|
171
|
+
if (await this.held(input.factId))
|
|
161
172
|
throw new Error(`fact ${input.factId} is on legal hold; release it first`);
|
|
162
173
|
const txTime = this.now().toISOString();
|
|
163
174
|
const text = canonical(prior.fact.value);
|
|
164
175
|
const digestKind = input.keepDigest === false ? "none" : this.forgetKey ? "hmac-sha256" : "sha256";
|
|
165
176
|
const valueDigest = digestKind === "none" ? null : digestKind === "hmac-sha256" ? createHmac("sha256", this.forgetKey).update(text).digest("hex") : createHash("sha256").update(text).digest("hex");
|
|
166
|
-
for (const e of this.
|
|
177
|
+
for (const e of await this.store.eventsFor(input.factId)) {
|
|
167
178
|
if (e.kind === "assert" && e.fact.factId === input.factId) {
|
|
168
|
-
e.fact
|
|
169
|
-
e.fact.forgotten = { valueDigest, digestKind, at: txTime };
|
|
170
|
-
this.store.replaceAssert(e);
|
|
179
|
+
await this.store.replaceAssert({ ...e, fact: { ...e.fact, value: null, forgotten: { valueDigest, digestKind, at: txTime } } });
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
const event = { eventId: randomUUID(), kind: "forget", txTime, factId: input.factId, actor: input.actor, reason: input.reason, source: input.source ?? { receiptId: null }, valueDigest, digestKind };
|
|
174
|
-
this.append(event);
|
|
183
|
+
await this.store.append(event);
|
|
184
|
+
if (input.compact !== false)
|
|
185
|
+
await this.store.compact();
|
|
175
186
|
return event;
|
|
176
187
|
}
|
|
188
|
+
/** Reclaims whatever the store may still hold of erased values; forget and sweep do this themselves unless told not to. */
|
|
189
|
+
compact() {
|
|
190
|
+
return this.store.compact();
|
|
191
|
+
}
|
|
177
192
|
/** The facts believed at a moment. Valid time answers "was it true then"; transaction time answers "did the ledger know it then". */
|
|
178
|
-
asOf(q = {}) {
|
|
193
|
+
async asOf(q = {}) {
|
|
179
194
|
const validAt = q.validAt ?? this.now().toISOString();
|
|
180
195
|
const txAt = q.txAt ?? this.now().toISOString();
|
|
181
|
-
const known = this.
|
|
196
|
+
const known = await this.store.eventsAbout({ txAtMost: txAt, ...(q.space === undefined ? {} : { space: q.space }), ...(q.subject === undefined ? {} : { subject: q.subject }), ...(q.predicate === undefined ? {} : { predicate: q.predicate }) });
|
|
182
197
|
const retracted = new Set(known.filter((e) => e.kind === "retract" || e.kind === "forget").map((e) => e.factId));
|
|
183
198
|
const confirmed = new Set(known.filter((e) => e.kind === "confirm").map((e) => e.factId));
|
|
184
199
|
const facts = new Map();
|
|
@@ -198,41 +213,43 @@ export class Ledger {
|
|
|
198
213
|
(q.predicate === undefined || f.predicate === q.predicate) &&
|
|
199
214
|
(q.include === undefined || q.include === "all" || RANK[f.provenance] >= RANK[q.include]));
|
|
200
215
|
}
|
|
216
|
+
/** One fact by id, whatever its state, with supersession applied; undefined when the ledger never held it. */
|
|
217
|
+
async get(factId) {
|
|
218
|
+
return (await this.factById(factId))?.fact;
|
|
219
|
+
}
|
|
201
220
|
/** Every fact ever asserted, with supersession applied and retracted ones included, for audits that must see everything. */
|
|
202
|
-
facts() {
|
|
221
|
+
async facts() {
|
|
222
|
+
const events = await this.store.events();
|
|
223
|
+
const retracted = new Set(events.filter((e) => e.kind === "retract" || e.kind === "forget").map((e) => e.factId));
|
|
203
224
|
const out = new Map();
|
|
204
|
-
for (const e of
|
|
225
|
+
for (const e of events) {
|
|
205
226
|
if (e.kind !== "assert")
|
|
206
227
|
continue;
|
|
207
228
|
out.set(e.fact.factId, { ...e.fact });
|
|
208
|
-
if (e.supersedes && out.has(e.supersedes) && !
|
|
229
|
+
if (e.supersedes && out.has(e.supersedes) && !retracted.has(e.fact.factId))
|
|
209
230
|
out.get(e.supersedes).validTo = e.fact.validFrom;
|
|
210
231
|
}
|
|
211
232
|
return [...out.values()];
|
|
212
233
|
}
|
|
213
234
|
/** Every event that touched a fact, oldest first: its assert, the assert that superseded it, its confirmation, its retraction. */
|
|
214
235
|
history(factId) {
|
|
215
|
-
return this.
|
|
236
|
+
return this.store.eventsFor(factId);
|
|
216
237
|
}
|
|
217
|
-
confirmedAt(factId) {
|
|
218
|
-
return this.
|
|
238
|
+
async confirmedAt(factId) {
|
|
239
|
+
return (await this.store.eventsFor(factId)).some((e) => e.kind === "confirm" && e.factId === factId);
|
|
219
240
|
}
|
|
220
|
-
factById(factId) {
|
|
241
|
+
async factById(factId) {
|
|
221
242
|
let found;
|
|
222
|
-
for (const e of this.
|
|
243
|
+
for (const e of await this.store.eventsFor(factId)) {
|
|
223
244
|
if (e.kind === "assert" && e.fact.factId === factId)
|
|
224
245
|
found = { ...e, fact: { ...e.fact } };
|
|
225
|
-
else if (e.kind === "assert" && e.supersedes === factId && found && !this.retractedAt(e.fact.factId))
|
|
246
|
+
else if (e.kind === "assert" && e.supersedes === factId && found && !(await this.retractedAt(e.fact.factId)))
|
|
226
247
|
found.fact.validTo = e.fact.validFrom;
|
|
227
248
|
}
|
|
228
249
|
return found;
|
|
229
250
|
}
|
|
230
|
-
retractedAt(factId) {
|
|
231
|
-
return this.
|
|
232
|
-
}
|
|
233
|
-
append(event) {
|
|
234
|
-
this.store.append(event);
|
|
235
|
-
this.events.push(event);
|
|
251
|
+
async retractedAt(factId) {
|
|
252
|
+
return (await this.store.eventsFor(factId)).some((e) => (e.kind === "retract" || e.kind === "forget") && e.factId === factId);
|
|
236
253
|
}
|
|
237
254
|
}
|
|
238
255
|
/** Canonical JSON: sorted keys, no whitespace, undefined dropped. The same encoding the receipts package uses. */
|