magenta-canon 0.3.0 → 0.4.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
@@ -170,6 +170,26 @@ npx magenta-canon verify bundle.json --expected-witness-key witness.pub
170
170
 
171
171
  Full walkthrough: [`docs/MCP_GATEWAY.md`](docs/MCP_GATEWAY.md).
172
172
 
173
+ ### Durable evidence (file ledger) — new in 0.4.0
174
+
175
+ By default evidence lives in memory (the demo's fresh-universe behavior). For
176
+ evidence that **survives a process restart**, point the gateway/control plane
177
+ at an append-only file ledger and pin the witness identity:
178
+
179
+ ```bash
180
+ MAGENTA_LEDGER_FILE=/var/lib/magenta/ledger.jsonl \
181
+ MAGENTA_WITNESS_SECRET=<hex> MAGENTA_WITNESS_PUBLIC=<hex> \
182
+ <your magenta process>
183
+ ```
184
+
185
+ Every receipt and signed tree head is fsync-committed before it is reported
186
+ recorded; on restart the ledger is strictly replayed (chain, issuer
187
+ signatures, Merkle roots, checkpoint history) or startup fails loudly. One
188
+ exclusive writer per ledger; corruption is refused, never repaired silently.
189
+ This is single-writer local/reference durability — see
190
+ [`docs/FILE_LEDGER.md`](docs/FILE_LEDGER.md) for the format, lock recovery,
191
+ crash-tail policy, backup guidance, and limitations.
192
+
173
193
  ### From the repo vs. as a CLI
174
194
 
175
195
  - **From a repo checkout** (above): `npm install` then `npm run demo`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package.json": "8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a",
3
- "scripts/demo-control-plane.js": "3357c8de8e3fe32e73e0be99a3bb02179dff3ee041854d4ee6f8d135e4d8dcbf",
3
+ "scripts/demo-control-plane.js": "6d58bd7e816029b3c23b8c3b5f8812a5bd8ae1f4b5d6a70851eeaf6cb577dbc1",
4
4
  "scripts/intake-cli.js": "189014db22d6fccb034ed1a93ec11efe8905be820bc468366c68bb2cabaf8a97",
5
5
  "scripts/magenta-mirror.js": "cf701065f7a6e20f7f44a9b815396aeb5fe031c3f003bcd793b8014ea8801b85",
6
6
  "scripts/magenta-verify.js": "f79c60944bef65926530c2d0fd5cc35df10319c5831764b035e547b6bfebe715",
@@ -9,6 +9,7 @@
9
9
  "server/agent-record.js": "790bbfa2a10601c2bdcd98873e6e41babdf96d1df7c7ca34f19791de4c8b860f",
10
10
  "server/crypto.js": "ebbcb2929e7b3651e4ec04c9fbf3dcb656e3ae0d30bf8864f3642f72f3be2f39",
11
11
  "server/execution-receipts.js": "171a506df7d1e99f3370de9a41c1a4f0b21b38d1f02edc78d9c44e68c93dbee4",
12
+ "server/file-ledger-store.js": "92dc58d313be183e53817455ee9fd4ab7cb2f3a171343de66f5db26e0f139d79",
12
13
  "server/intake/categories.js": "e9860eee0e2e0fe96c7ade165e5e068fae0874de2c7d39ff796efc91e713013b",
13
14
  "server/intake/engine.js": "abb8feacd925520cbf01acc2e932d9ebb037a3d016b053b7b10deaf8f545a605",
14
15
  "server/intake/gateway-intake.js": "40514fa489b031c29725e9b837e83998217bbc1ae84cf9259154a16b1faae2e8",
@@ -20,11 +21,13 @@
20
21
  "server/intake/rationale.js": "3ea64a1430828ae5a06b41f4e93d06cdd667850d6a2ff9a1e266f98bdfc6c404",
21
22
  "server/intake/types.js": "014891c6c8360648d6954b3185ae92d56be625484bf2f0d0f71dac271a59e23b",
22
23
  "server/intake/witness.js": "588ad166f4870d65a6ed334c70aaa2deb1d6d96ec97d889902a9eda7528bd34f",
24
+ "server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
25
+ "server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
23
26
  "server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
24
- "server/storage.js": "4f1660e9542dcb157148f561efd757d8aa452730c3a09064d3b1314feb16e8c9",
25
- "server/transparency-log.js": "42d0d9fd3de9c60389ca882b546d9784e6ef0003895898dd5958d763d54f2f6b",
27
+ "server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
28
+ "server/transparency-log.js": "826dbae845726853b0736d10546a2c65596357530b82af0d1a781ac368c69a79",
26
29
  "server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
27
- "server/witness.js": "e94384a35a4cfce39990e580eb5b572ef9a2d4c4d9bad1a2ed9b5382aba64b1b",
30
+ "server/witness.js": "1963401588a48817f7e478d3824a4752d8a3f438f277ba4443158abcedfd6a47",
28
31
  "shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
29
32
  "shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
30
33
  "shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
@@ -33,6 +33,7 @@ const node_crypto_1 = require("node:crypto");
33
33
  const storage_1 = require("../server/storage");
34
34
  const witness_1 = require("../server/witness");
35
35
  const agent_record_1 = require("../server/agent-record");
36
+ const witness_2 = require("../server/witness");
36
37
  const trust_bootstrap_1 = require("../server/trust-bootstrap");
37
38
  const PORT = Number(process.env.PORT ?? 0);
38
39
  const HOST = "127.0.0.1";
@@ -164,6 +165,13 @@ const server = (0, node_http_1.createServer)(async (req, res) => {
164
165
  sendJson(res, 500, { error: "internal_error", message: e instanceof Error ? e.message : String(e) });
165
166
  }
166
167
  });
168
+ // File-ledger restart support: rebuild the witness's derived leaf view from
169
+ // any receipts the durable store replayed (no-op in the default memory mode).
170
+ void (async () => {
171
+ const restored = await storage_1.storage.getExecutionReceipts();
172
+ if (restored.length > 0)
173
+ (0, witness_2.rebuildWitnessFromReceipts)([...restored].reverse().map((r) => r.receipt_hash));
174
+ })();
167
175
  server.listen(PORT, HOST, () => {
168
176
  const addr = server.address();
169
177
  const port = typeof addr === "object" && addr ? addr.port : PORT;
@@ -0,0 +1,411 @@
1
+ "use strict";
2
+ /**
3
+ * FILE LEDGER STORE — Durable Witness PR 2.
4
+ *
5
+ * Append-only, fsync-committed, exclusively-locked file implementation of the
6
+ * LedgerStore contract (server/ledger-store.ts). This is the first lane where
7
+ * Magenta evidence survives a real process restart:
8
+ *
9
+ * action → receipt appended → write + fsync → "committed" returned
10
+ * → restart → strict replay → same chain, same Merkle root, same STH
11
+ * history → evidence still verifies (or startup FAILS LOUDLY).
12
+ *
13
+ * THE COMMIT GUARANTEE: a receipt or STH checkpoint is reported committed
14
+ * only after its record has been written and fsync'd. The in-memory view
15
+ * advances only after the durable write succeeds. A failed write surfaces an
16
+ * error, leaves the in-memory state unchanged, and never silently degrades
17
+ * to memory-only mode.
18
+ *
19
+ * FILE FORMAT — canonical JSON Lines (auditable with `less`):
20
+ * {"v":1,"t":"header","genesis":"<64hex>","created":"<iso>"}
21
+ * {"v":1,"t":"receipt","seq":<n>,"payload":{...receipt...},"payload_hash":"<canonicalHash(payload)>"}
22
+ * {"v":1,"t":"sth","payload":{...sth...},"payload_hash":"<canonicalHash(payload)>"}
23
+ * One record per line, no pretty-printing, bounded line size (MAX_LINE bytes).
24
+ * Unknown versions/types and malformed committed records FAIL CLOSED. The
25
+ * store never reinterprets, repairs, re-sorts, or silently drops history.
26
+ *
27
+ * CRASH-TAIL POLICY (strict, two distinct cases):
28
+ * - INCOMPLETE FINAL TAIL (no terminating newline, or the final
29
+ * newline-terminated fragment is not parseable JSON): provably never a
30
+ * committed record (commit = full line + "\n" + fsync). It is QUARANTINED
31
+ * to `<ledger>.tail-quarantine-<ts>` and truncated, with a loud audit log.
32
+ * - COMPLETE BUT INVALID RECORD (parses, but bad hash / broken chain /
33
+ * duplicate / conflicting STH / bad signature), anywhere including last:
34
+ * CORRUPTION → startup refuses with line number and reason. No automatic
35
+ * truncation, no blank universe, no backup auto-selection.
36
+ *
37
+ * EXCLUSIVE WRITER: `<ledger>.lock` created with O_EXCL, containing
38
+ * {pid, hostname, created, nonce, ledger}. A second writer fails before
39
+ * accepting any traffic. A stale lock is REPORTED with its contents and the
40
+ * exact operator recovery step — never auto-deleted on a PID heuristic.
41
+ *
42
+ * APPEND-ONLY SEMANTICS FOR LIFECYCLE METHODS: clearReceipts/clearSths
43
+ * (test-isolation hooks on the memory store) THROW here — a durable evidence
44
+ * ledger refuses destructive clears. restoreReceipts accepts only a snapshot
45
+ * matching the current committed tail (the mutation-rollback path after a
46
+ * FAILED append is a no-op by construction); restoring any other state would
47
+ * rewrite history and throws.
48
+ *
49
+ * SNAPSHOT COHERENCE: all writes are synchronous under the single-threaded
50
+ * event loop, so reads can never observe a torn receipt/STH combination;
51
+ * snapshotReceipts() copies, exactly like the memory store.
52
+ *
53
+ * Scope honesty: single-writer, single-host, reference-grade durability.
54
+ * NOT multi-tenant, NOT hosted production infrastructure — that is the
55
+ * PostgresLedgerStore lane (architecture §2.2 option A).
56
+ */
57
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
58
+ if (k2 === undefined) k2 = k;
59
+ var desc = Object.getOwnPropertyDescriptor(m, k);
60
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
61
+ desc = { enumerable: true, get: function() { return m[k]; } };
62
+ }
63
+ Object.defineProperty(o, k2, desc);
64
+ }) : (function(o, m, k, k2) {
65
+ if (k2 === undefined) k2 = k;
66
+ o[k2] = m[k];
67
+ }));
68
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
69
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
70
+ }) : function(o, v) {
71
+ o["default"] = v;
72
+ });
73
+ var __importStar = (this && this.__importStar) || function (mod) {
74
+ if (mod && mod.__esModule) return mod;
75
+ var result = {};
76
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
77
+ __setModuleDefault(result, mod);
78
+ return result;
79
+ };
80
+ Object.defineProperty(exports, "__esModule", { value: true });
81
+ exports.FileLedgerStore = exports.LedgerLockedError = exports.LedgerCorruptionError = void 0;
82
+ const fs = __importStar(require("node:fs"));
83
+ const path = __importStar(require("node:path"));
84
+ const os = __importStar(require("node:os"));
85
+ const node_crypto_1 = require("node:crypto");
86
+ const canonical_1 = require("../shared/canonical");
87
+ const crypto_1 = require("./crypto");
88
+ const transparency_log_1 = require("./transparency-log");
89
+ const FORMAT_VERSION = 1;
90
+ const MAX_LINE = 1024 * 1024; // 1 MiB per record — bounded, fail-closed
91
+ class LedgerCorruptionError extends Error {
92
+ line;
93
+ constructor(line, reason) {
94
+ super(`file-ledger: CORRUPTION at record ${line}: ${reason} — startup refused. ` +
95
+ `History is never repaired automatically; restore from a verified backup or investigate the record.`);
96
+ this.line = line;
97
+ }
98
+ }
99
+ exports.LedgerCorruptionError = LedgerCorruptionError;
100
+ class LedgerLockedError extends Error {
101
+ constructor(lockPath, holder) {
102
+ super(`file-ledger: ledger is locked by another writer (${holder}). ` +
103
+ `If that process is dead, verify it first, then remove the lock file manually: ${lockPath}. ` +
104
+ `This is a deliberate operator action and is never done automatically.`);
105
+ }
106
+ }
107
+ exports.LedgerLockedError = LedgerLockedError;
108
+ const realFileOps = {
109
+ appendAndSync(fd, data) {
110
+ fs.writeSync(fd, data);
111
+ fs.fsyncSync(fd);
112
+ },
113
+ };
114
+ const HEX64 = /^[0-9a-f]{64}$/;
115
+ class FileLedgerStore {
116
+ filePath;
117
+ genesis;
118
+ ops;
119
+ receipts = [];
120
+ index = new Map();
121
+ lastHash;
122
+ sths = [];
123
+ sthBySize = new Map(); // tree_size -> root_hash (one root per size)
124
+ leaves = []; // replay-validation view only
125
+ fd;
126
+ lockPath;
127
+ lockNonce;
128
+ closed = false;
129
+ constructor(filePath, genesis = canonical_1.GENESIS_HASH, ops = realFileOps) {
130
+ this.filePath = filePath;
131
+ this.genesis = genesis;
132
+ this.ops = ops;
133
+ this.lastHash = genesis;
134
+ if (!path.isAbsolute(filePath)) {
135
+ throw new Error(`file-ledger: MAGENTA_LEDGER_FILE must be an absolute path (got '${filePath}') — relative paths change meaning with the working directory.`);
136
+ }
137
+ const dir = path.dirname(filePath);
138
+ if (!fs.existsSync(dir)) {
139
+ throw new Error(`file-ledger: parent directory does not exist: ${dir} — create it deliberately (the ledger never invents directories).`);
140
+ }
141
+ // ── exclusive writer lock (atomic O_EXCL; never auto-stolen) ────────────
142
+ this.lockPath = `${filePath}.lock`;
143
+ this.lockNonce = (0, node_crypto_1.randomBytes)(8).toString("hex");
144
+ try {
145
+ const lockFd = fs.openSync(this.lockPath, "wx", 0o600);
146
+ fs.writeSync(lockFd, JSON.stringify({
147
+ pid: process.pid, hostname: os.hostname(),
148
+ created: new Date().toISOString(), nonce: this.lockNonce, ledger: filePath,
149
+ }));
150
+ fs.fsyncSync(lockFd);
151
+ fs.closeSync(lockFd);
152
+ }
153
+ catch (e) {
154
+ if (e.code === "EEXIST") {
155
+ let holder = "unreadable lock file";
156
+ try {
157
+ const lk = JSON.parse(fs.readFileSync(this.lockPath, "utf8"));
158
+ const alive = (() => { try {
159
+ process.kill(lk.pid, 0);
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ } })();
165
+ holder = `pid ${lk.pid}@${lk.hostname}, created ${lk.created}, pid ${alive ? "STILL RUNNING" : "not found on this host (possibly stale — verify before removing)"}`;
166
+ }
167
+ catch { /* malformed lock also fails closed */ }
168
+ throw new LedgerLockedError(this.lockPath, holder);
169
+ }
170
+ throw e;
171
+ }
172
+ // ── open + strict replay ────────────────────────────────────────────────
173
+ try {
174
+ const existed = fs.existsSync(filePath);
175
+ this.fd = fs.openSync(filePath, existed ? "r+" : "ax", 0o600);
176
+ if (!existed) {
177
+ this.writeRecord({ v: FORMAT_VERSION, t: "header", genesis, created: new Date().toISOString() });
178
+ }
179
+ else {
180
+ this.replay();
181
+ // position for appends
182
+ const end = fs.fstatSync(this.fd).size;
183
+ fs.closeSync(this.fd);
184
+ this.fd = fs.openSync(filePath, "a");
185
+ void end;
186
+ }
187
+ }
188
+ catch (e) {
189
+ this.releaseLock();
190
+ throw e;
191
+ }
192
+ }
193
+ // ── durable append protocol: serialize → append → fsync → THEN update memory
194
+ writeRecord(rec) {
195
+ if (this.closed)
196
+ throw new Error("file-ledger: store is closed");
197
+ const line = JSON.stringify(rec) + "\n";
198
+ if (Buffer.byteLength(line) > MAX_LINE) {
199
+ throw new Error(`file-ledger: record exceeds the ${MAX_LINE}-byte line bound — refused`);
200
+ }
201
+ this.ops.appendAndSync(this.fd, line); // throws on append/flush/fsync failure
202
+ }
203
+ async appendReceipt(receipt) {
204
+ // Candidate validation (the PR-1 contract activates here):
205
+ const r = receipt;
206
+ if (typeof r.receipt_hash !== "string" || !HEX64.test(r.receipt_hash)) {
207
+ throw new Error("file-ledger: receipt_hash missing/malformed — refused");
208
+ }
209
+ if (this.index.has(receipt.receipt_hash)) {
210
+ throw new Error(`file-ledger: duplicate receipt_hash ${receipt.receipt_hash.slice(0, 16)}… — append-only ledger refuses duplicates`);
211
+ }
212
+ if (r.previous_receipt_hash !== this.lastHash) {
213
+ throw new Error(`file-ledger: previous_receipt_hash does not match the committed tail — refused (expected ${this.lastHash.slice(0, 16)}…)`);
214
+ }
215
+ const payload = { ...receipt };
216
+ this.writeRecord({
217
+ v: FORMAT_VERSION, t: "receipt", seq: this.receipts.length + 1,
218
+ payload, payload_hash: (0, canonical_1.canonicalHash)(payload),
219
+ });
220
+ // Only after the durable write: advance the committed view.
221
+ this.receipts.push(receipt);
222
+ this.index.set(receipt.receipt_hash, receipt);
223
+ this.lastHash = receipt.receipt_hash;
224
+ this.leaves.push((0, transparency_log_1.hashLeaf)(receipt.receipt_hash));
225
+ }
226
+ appendSth(sth) {
227
+ if (typeof sth.tree_size !== "number" || sth.tree_size < 0) {
228
+ throw new Error("file-ledger: STH tree_size malformed — refused");
229
+ }
230
+ const existing = this.sthBySize.get(sth.tree_size);
231
+ if (existing !== undefined && existing !== sth.root_hash) {
232
+ throw new Error(`file-ledger: conflicting STH at tree_size ${sth.tree_size} — equivocation refused`);
233
+ }
234
+ const payload = { ...sth };
235
+ this.writeRecord({ v: FORMAT_VERSION, t: "sth", payload, payload_hash: (0, canonical_1.canonicalHash)(payload) });
236
+ this.sths.push(sth);
237
+ this.sthBySize.set(sth.tree_size, sth.root_hash);
238
+ }
239
+ // ── reads (same copy semantics as the memory store) ───────────────────────
240
+ async getReceiptsNewestFirst(limit) {
241
+ const out = [...this.receipts].reverse();
242
+ return limit ? out.slice(0, limit) : out;
243
+ }
244
+ async getReceiptByHash(receiptHash) {
245
+ return this.index.get(receiptHash);
246
+ }
247
+ async getLastReceiptHash() {
248
+ return this.lastHash;
249
+ }
250
+ latestSth() {
251
+ return this.sths[this.sths.length - 1];
252
+ }
253
+ allSths() {
254
+ return this.sths.slice();
255
+ }
256
+ snapshotReceipts() {
257
+ return { receipts: [...this.receipts], index: new Map(this.index), lastReceiptHash: this.lastHash };
258
+ }
259
+ // ── append-only refusals ───────────────────────────────────────────────────
260
+ clearReceipts() {
261
+ throw new Error("file-ledger: append-only evidence ledger refuses destructive clearReceipts() — use a fresh ledger file for a fresh universe");
262
+ }
263
+ clearSths() {
264
+ throw new Error("file-ledger: append-only evidence ledger refuses destructive clearSths()");
265
+ }
266
+ restoreReceipts(snapshot) {
267
+ // The mutation-rollback path restores the snapshot taken BEFORE a failed
268
+ // append; a failed durable append never advanced state, so an identical
269
+ // tail is a no-op. Anything else would rewrite committed history.
270
+ if (snapshot.lastReceiptHash === this.lastHash && snapshot.receipts.length === this.receipts.length)
271
+ return;
272
+ throw new Error("file-ledger: restoreReceipts would rewrite committed append-only history — refused");
273
+ }
274
+ /** Release the writer lock and close the file. Idempotent. */
275
+ close() {
276
+ if (this.closed)
277
+ return;
278
+ this.closed = true;
279
+ try {
280
+ fs.closeSync(this.fd);
281
+ }
282
+ catch { /* already closed */ }
283
+ this.releaseLock();
284
+ }
285
+ releaseLock() {
286
+ try {
287
+ const lk = JSON.parse(fs.readFileSync(this.lockPath, "utf8"));
288
+ if (lk.nonce === this.lockNonce)
289
+ fs.unlinkSync(this.lockPath);
290
+ }
291
+ catch { /* lock already gone or not ours — leave it */ }
292
+ }
293
+ // ── strict startup replay ──────────────────────────────────────────────────
294
+ replay() {
295
+ const raw = fs.readFileSync(this.filePath, "utf8");
296
+ let body = raw;
297
+ // Incomplete final tail: bytes after the last newline can never be a
298
+ // committed record (commit = line + "\n" + fsync). Quarantine + truncate.
299
+ const lastNl = raw.lastIndexOf("\n");
300
+ const tail = raw.slice(lastNl + 1);
301
+ if (tail.length > 0) {
302
+ this.quarantineTail(tail, lastNl + 1);
303
+ body = raw.slice(0, lastNl + 1);
304
+ }
305
+ else if (lastNl >= 0) {
306
+ // The final newline-terminated line might still be a torn write that
307
+ // happens to end in "\n"? No: writes are single appendAndSync calls of
308
+ // full lines; a torn final line is only possible WITHOUT its newline.
309
+ // A complete line that fails to parse is corruption (handled below).
310
+ }
311
+ const lines = body.length ? body.split("\n").slice(0, -1) : [];
312
+ let recNo = 0;
313
+ let headerSeen = false;
314
+ let witnessKey;
315
+ for (const line of lines) {
316
+ recNo++;
317
+ if (Buffer.byteLength(line) > MAX_LINE)
318
+ throw new LedgerCorruptionError(recNo, "record exceeds line bound");
319
+ let rec;
320
+ try {
321
+ rec = JSON.parse(line);
322
+ }
323
+ catch {
324
+ // A complete (newline-terminated) record that does not parse, with or
325
+ // without records after it, is corruption — never auto-truncated.
326
+ throw new LedgerCorruptionError(recNo, "committed record is not valid JSON");
327
+ }
328
+ if (rec.v !== FORMAT_VERSION)
329
+ throw new LedgerCorruptionError(recNo, `unsupported format version ${rec.v}`);
330
+ if (rec.t === "header") {
331
+ if (recNo !== 1)
332
+ throw new LedgerCorruptionError(recNo, "header record not first");
333
+ if (rec.genesis !== this.genesis)
334
+ throw new LedgerCorruptionError(recNo, `genesis mismatch (file ${rec.genesis}, expected ${this.genesis})`);
335
+ headerSeen = true;
336
+ continue;
337
+ }
338
+ if (!headerSeen)
339
+ throw new LedgerCorruptionError(recNo, "missing header record");
340
+ if (!rec.payload || typeof rec.payload !== "object")
341
+ throw new LedgerCorruptionError(recNo, "missing payload");
342
+ if (rec.payload_hash !== (0, canonical_1.canonicalHash)(rec.payload))
343
+ throw new LedgerCorruptionError(recNo, "payload_hash mismatch (record altered on disk)");
344
+ if (rec.t === "receipt") {
345
+ const rc = rec.payload;
346
+ const rr = rec.payload;
347
+ if (rec.seq !== this.receipts.length + 1)
348
+ throw new LedgerCorruptionError(recNo, `sequence mismatch (got ${rec.seq}, expected ${this.receipts.length + 1})`);
349
+ if (typeof rr.receipt_hash !== "string" || !HEX64.test(rr.receipt_hash))
350
+ throw new LedgerCorruptionError(recNo, "receipt_hash malformed");
351
+ if (this.index.has(rc.receipt_hash))
352
+ throw new LedgerCorruptionError(recNo, "duplicate receipt_hash");
353
+ if (rr.previous_receipt_hash !== this.lastHash)
354
+ throw new LedgerCorruptionError(recNo, "previous_receipt_hash breaks the chain");
355
+ // receipt_hash recomputation (chain layer)
356
+ if (typeof rr.execution_hash === "string" &&
357
+ (0, canonical_1.chainHash)(rr.previous_receipt_hash, rr.execution_hash) !== rc.receipt_hash) {
358
+ throw new LedgerCorruptionError(recNo, "receipt_hash does not recompute from previous_receipt_hash + execution_hash");
359
+ }
360
+ // issuer signature (Ed25519 over canonical body minus receipt_signature)
361
+ if (typeof rr.receipt_signature === "string" && typeof rr.issuer_pubkey === "string") {
362
+ const bodyNoSig = {};
363
+ for (const k of Object.keys(rr))
364
+ if (k !== "receipt_signature")
365
+ bodyNoSig[k] = rr[k];
366
+ if (!(0, crypto_1.verify)((0, canonical_1.canonicalHash)(bodyNoSig), rr.receipt_signature, rr.issuer_pubkey)) {
367
+ throw new LedgerCorruptionError(recNo, "issuer signature invalid");
368
+ }
369
+ }
370
+ this.receipts.push(rc);
371
+ this.index.set(rc.receipt_hash, rc);
372
+ this.lastHash = rc.receipt_hash;
373
+ this.leaves.push((0, transparency_log_1.hashLeaf)(rc.receipt_hash));
374
+ continue;
375
+ }
376
+ if (rec.t === "sth") {
377
+ const sth = rec.payload;
378
+ if (!(0, transparency_log_1.verifySTH)(sth))
379
+ throw new LedgerCorruptionError(recNo, "STH signature invalid");
380
+ if (witnessKey === undefined)
381
+ witnessKey = sth.witness_pubkey;
382
+ else if (sth.witness_pubkey !== witnessKey)
383
+ throw new LedgerCorruptionError(recNo, "witness key changed mid-ledger (rotation records are a later lane)");
384
+ if (sth.tree_size > this.leaves.length)
385
+ throw new LedgerCorruptionError(recNo, `STH tree_size ${sth.tree_size} exceeds receipts seen (${this.leaves.length})`);
386
+ const existing = this.sthBySize.get(sth.tree_size);
387
+ if (existing !== undefined && existing !== sth.root_hash)
388
+ throw new LedgerCorruptionError(recNo, `conflicting STH roots at tree_size ${sth.tree_size} (equivocation at rest)`);
389
+ if ((0, transparency_log_1.merkleRoot)(this.leaves.slice(0, sth.tree_size)) !== sth.root_hash) {
390
+ throw new LedgerCorruptionError(recNo, "STH root_hash does not recompute from the receipt chain");
391
+ }
392
+ this.sths.push(sth);
393
+ this.sthBySize.set(sth.tree_size, sth.root_hash);
394
+ continue;
395
+ }
396
+ throw new LedgerCorruptionError(recNo, `unknown record type '${rec.t}'`);
397
+ }
398
+ if (lines.length > 0 && !headerSeen)
399
+ throw new LedgerCorruptionError(1, "missing header record");
400
+ }
401
+ quarantineTail(tail, offset) {
402
+ const qPath = `${this.filePath}.tail-quarantine-${Date.now()}`;
403
+ fs.writeFileSync(qPath, tail, { mode: 0o600 });
404
+ fs.truncateSync(this.filePath, offset);
405
+ // Loud, deterministic, auditable — an operator can inspect the quarantined bytes.
406
+ console.error(`[file-ledger] AUDIT: incomplete uncommitted tail (${Buffer.byteLength(tail)} bytes at offset ${offset}) ` +
407
+ `quarantined to ${qPath} and truncated. This tail never completed its commit (no terminating newline); ` +
408
+ `committed history is unaffected.`);
409
+ }
410
+ }
411
+ exports.FileLedgerStore = FileLedgerStore;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ /**
3
+ * LEDGER STORE — the durable-evidence seam (Durable Witness PR 1).
4
+ *
5
+ * This interface is the storage contract for Magenta's evidence ledger: the
6
+ * ordered execution-receipt chain and the signed-tree-head (STH) checkpoint
7
+ * history. It exists so durability can be added behind a seam (FileLedgerStore,
8
+ * PostgresLedgerStore — see docs/roadmaps/DURABLE_WITNESS_ARCHITECTURE.md)
9
+ * without rewriting the transparency log, witness, receipts, verifier,
10
+ * gateway, or any public API surface.
11
+ *
12
+ * WHAT THE STORE IS NOT (semantics stay in their existing layers):
13
+ * - it does NOT generate keys, sign anything, or compute receipt hashes;
14
+ * - it does NOT decide authorization or policy outcomes;
15
+ * - it does NOT verify evidence;
16
+ * - it does NOT repair, re-sort, or rewrite history — it preserves exactly
17
+ * what was appended, in the order it was appended, and propagates errors
18
+ * rather than suppressing them.
19
+ *
20
+ * Merkle LEAVES deliberately do NOT live here: they are a derivable view of
21
+ * the receipt sequence (architecture §2.2) and remain in TransparencyLog,
22
+ * rebuilt from receipts on boot when persistence is enabled.
23
+ *
24
+ * SYNC/ASYNC BOUNDARY (deliberate, staged):
25
+ * - Receipt operations are Promise-based — every existing call site already
26
+ * awaits them (IStorage has been async since inception), so a future
27
+ * PostgresLedgerStore slots in without caller churn.
28
+ * - STH operations are synchronous today because TransparencyLog.append()
29
+ * is synchronous and is called fire-and-forget from witnessReceipt().
30
+ * EVOLUTION POINT (PR 4, Postgres lane): when STH writes become durable,
31
+ * witnessing moves behind an async ledger service; the interface gains
32
+ * async STH variants THEN, alongside the fail-closed integration the
33
+ * architecture prescribes — not speculatively now.
34
+ *
35
+ * ATOMIC APPEND CONTRACT (normative for future durable implementations):
36
+ * - A receipt is either durably committed exactly once, or not recorded at
37
+ * all; "recorded" means COMMITTED (the durable store must not acknowledge
38
+ * before its transaction/fsync boundary).
39
+ * - Sequence is allocated by the store (array position today; DB identity /
40
+ * file offset later) — never by an external in-memory counter.
41
+ * - `receipt_hash` is unique: a durable store MUST reject a duplicate
42
+ * append (constraint violation), and MUST verify the incoming receipt's
43
+ * `previous_receipt_hash` equals the current tail before committing.
44
+ * - One STH per tree_size: a durable store MUST make equivocation at rest a
45
+ * constraint violation (tree_size as primary key).
46
+ * - Partial failure must surface as an error to the caller; the store never
47
+ * silently retries into an inconsistent state, and a failed append leaves
48
+ * the tail unchanged so a retry is safe.
49
+ * MemoryLedgerStore intentionally does NOT enforce the duplicate/previous-
50
+ * hash checks above: it byte-for-byte preserves the behavior the in-memory
51
+ * ledger has always had (push, index, advance tail), because PR 1 is
52
+ * non-semantic. The contract text governs PR 2+ implementations.
53
+ */
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.MemoryLedgerStore = void 0;
56
+ const canonical_1 = require("../shared/canonical");
57
+ /**
58
+ * In-memory implementation — behavior-identical to the state previously held
59
+ * inline by MemStorage (receipts) and TransparencyLog (sthHistory).
60
+ * Deterministic order, no I/O, no environment variables, no background work.
61
+ */
62
+ class MemoryLedgerStore {
63
+ receipts = [];
64
+ index = new Map();
65
+ lastHash;
66
+ sths = [];
67
+ constructor(genesisHash = canonical_1.GENESIS_HASH) {
68
+ this.lastHash = genesisHash;
69
+ this.genesis = genesisHash;
70
+ }
71
+ genesis;
72
+ async appendReceipt(receipt) {
73
+ // Exact historical semantics: push, index by hash, advance tail.
74
+ this.receipts.push(receipt);
75
+ this.index.set(receipt.receipt_hash, receipt);
76
+ this.lastHash = receipt.receipt_hash;
77
+ }
78
+ async getReceiptsNewestFirst(limit) {
79
+ const out = [...this.receipts].reverse();
80
+ return limit ? out.slice(0, limit) : out;
81
+ }
82
+ async getReceiptByHash(receiptHash) {
83
+ return this.index.get(receiptHash);
84
+ }
85
+ async getLastReceiptHash() {
86
+ return this.lastHash;
87
+ }
88
+ appendSth(sth) {
89
+ this.sths.push(sth);
90
+ }
91
+ latestSth() {
92
+ return this.sths[this.sths.length - 1];
93
+ }
94
+ allSths() {
95
+ return this.sths.slice();
96
+ }
97
+ clearReceipts() {
98
+ this.receipts = [];
99
+ this.index = new Map();
100
+ this.lastHash = this.genesis;
101
+ }
102
+ clearSths() {
103
+ this.sths = [];
104
+ }
105
+ snapshotReceipts() {
106
+ return {
107
+ receipts: [...this.receipts],
108
+ index: new Map(this.index),
109
+ lastReceiptHash: this.lastHash,
110
+ };
111
+ }
112
+ restoreReceipts(snapshot) {
113
+ this.receipts = snapshot.receipts;
114
+ this.index = snapshot.index;
115
+ this.lastHash = snapshot.lastReceiptHash;
116
+ }
117
+ }
118
+ exports.MemoryLedgerStore = MemoryLedgerStore;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sharedLedgerStore = void 0;
4
+ /**
5
+ * LEDGER COMPOSITION POINT — selects the process-wide evidence-ledger store.
6
+ *
7
+ * Modes (explicit, never accidental):
8
+ * - MEMORY (default): no env var set. MemStorage and TransparencyLog each
9
+ * construct their own MemoryLedgerStore — the historical behavior.
10
+ * - FILE: MAGENTA_LEDGER_FILE=/absolute/path/to/magenta-ledger.jsonl
11
+ * ONE FileLedgerStore instance owns BOTH halves (receipts + STH
12
+ * checkpoints) in one append-only file under one writer lock — receipts
13
+ * and checkpoints can never split across two state universes.
14
+ *
15
+ * NO AMBIGUOUS DUAL AUTHORITY: the legacy whole-state dump
16
+ * (MAGENTA_STATE_FILE) also persists receipt state. Setting both variables is
17
+ * a configuration error and FAILS STARTUP — evidence must have exactly one
18
+ * durable authority. Migration from a legacy state file is a documented,
19
+ * deliberate operator action (docs/FILE_LEDGER.md), not an automatic merge.
20
+ *
21
+ * NO SILENT FALLBACK: if MAGENTA_LEDGER_FILE is set but the ledger cannot be
22
+ * opened (bad path, lock held, corruption), the error propagates and the
23
+ * process fails to start. A configured durable ledger never degrades to a
24
+ * blank in-memory universe.
25
+ */
26
+ const file_ledger_store_1 = require("./file-ledger-store");
27
+ function resolveSharedLedgerStore() {
28
+ const file = process.env.MAGENTA_LEDGER_FILE;
29
+ if (!file)
30
+ return undefined;
31
+ if (process.env.MAGENTA_STATE_FILE) {
32
+ throw new Error("ledger: MAGENTA_LEDGER_FILE and MAGENTA_STATE_FILE are both set — refusing to start with two " +
33
+ "independent persistence authorities over receipt state. Unset one (see docs/FILE_LEDGER.md for migration).");
34
+ }
35
+ return new file_ledger_store_1.FileLedgerStore(file);
36
+ }
37
+ /** Shared durable store when file mode is configured; undefined in memory mode. */
38
+ exports.sharedLedgerStore = resolveSharedLedgerStore();
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resetStorage = exports.storage = exports.MemStorage = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const canonical_1 = require("../shared/canonical");
6
+ const ledger_store_1 = require("./ledger-store");
7
+ const ledger_1 = require("./ledger");
6
8
  const schema_1 = require("../shared/schema");
7
9
  // Genesis hash for first audit entry
8
10
  const GENESIS_HASH = "GENESIS";
@@ -52,9 +54,10 @@ class MemStorage {
52
54
  revocationChain;
53
55
  revokedPubkeys;
54
56
  lastRevocationHash;
55
- executionReceipts;
56
- receiptIndex;
57
- lastReceiptHash;
57
+ // Durable Witness PR 1: the evidence-ledger receipt chain lives behind the
58
+ // LedgerStore seam (see server/ledger-store.ts). MemoryLedgerStore preserves
59
+ // the exact historical in-memory semantics.
60
+ ledger;
58
61
  usedNonces; // nonce -> timestamp
59
62
  secretKeyStore; // pubkey -> secretKey (server-side only)
60
63
  policyBundles; // keyed by `${policy_id}:${version}`
@@ -103,9 +106,9 @@ class MemStorage {
103
106
  this.revocationChain = [];
104
107
  this.revokedPubkeys = new Set();
105
108
  this.lastRevocationHash = canonical_1.GENESIS_HASH;
106
- this.executionReceipts = [];
107
- this.receiptIndex = new Map();
108
- this.lastReceiptHash = canonical_1.GENESIS_HASH;
109
+ // FILE mode (MAGENTA_LEDGER_FILE) shares ONE durable store with the
110
+ // witness; MEMORY mode keeps the historical per-owner in-memory store.
111
+ this.ledger = ledger_1.sharedLedgerStore ?? new ledger_store_1.MemoryLedgerStore(canonical_1.GENESIS_HASH);
109
112
  this.usedNonces = new Map();
110
113
  this.secretKeyStore = new Map();
111
114
  this.policyBundles = new Map();
@@ -147,9 +150,7 @@ class MemStorage {
147
150
  this.revocationChain = [];
148
151
  this.revokedPubkeys = new Set();
149
152
  this.lastRevocationHash = canonical_1.GENESIS_HASH;
150
- this.executionReceipts = [];
151
- this.receiptIndex = new Map();
152
- this.lastReceiptHash = canonical_1.GENESIS_HASH;
153
+ this.ledger.clearReceipts();
153
154
  this.usedNonces = new Map();
154
155
  this.secretKeyStore = new Map();
155
156
  this.policyBundles = new Map();
@@ -1037,19 +1038,16 @@ class MemStorage {
1037
1038
  // SOVEREIGN TRUST ARCHITECTURE: Execution Receipts
1038
1039
  // ========================================================================
1039
1040
  async appendExecutionReceipt(receipt) {
1040
- this.executionReceipts.push(receipt);
1041
- this.receiptIndex.set(receipt.receipt_hash, receipt);
1042
- this.lastReceiptHash = receipt.receipt_hash;
1041
+ return this.ledger.appendReceipt(receipt);
1043
1042
  }
1044
1043
  async getExecutionReceipts(limit) {
1045
- const receipts = [...this.executionReceipts].reverse();
1046
- return limit ? receipts.slice(0, limit) : receipts;
1044
+ return this.ledger.getReceiptsNewestFirst(limit);
1047
1045
  }
1048
1046
  async getExecutionReceipt(receiptHash) {
1049
- return this.receiptIndex.get(receiptHash);
1047
+ return this.ledger.getReceiptByHash(receiptHash);
1050
1048
  }
1051
1049
  async getLastReceiptHash() {
1052
- return this.lastReceiptHash;
1050
+ return this.ledger.getLastReceiptHash();
1053
1051
  }
1054
1052
  // ========================================================================
1055
1053
  // SOVEREIGN TRUST ARCHITECTURE: Nonce Replay Protection
@@ -1142,9 +1140,14 @@ class MemStorage {
1142
1140
  certificates: new Map(this.certificates),
1143
1141
  rootCertPubkey: this.rootCertPubkey,
1144
1142
  secretKeyStore: new Map(this.secretKeyStore),
1145
- executionReceipts: [...this.executionReceipts],
1146
- receiptIndex: new Map(this.receiptIndex),
1147
- lastReceiptHash: this.lastReceiptHash,
1143
+ ...(() => {
1144
+ const l = this.ledger.snapshotReceipts();
1145
+ return {
1146
+ executionReceipts: l.receipts,
1147
+ receiptIndex: l.index,
1148
+ lastReceiptHash: l.lastReceiptHash,
1149
+ };
1150
+ })(),
1148
1151
  policyBundles: new Map(this.policyBundles),
1149
1152
  };
1150
1153
  }
@@ -1164,9 +1167,11 @@ class MemStorage {
1164
1167
  this.certificates = snapshot.certificates;
1165
1168
  this.rootCertPubkey = snapshot.rootCertPubkey;
1166
1169
  this.secretKeyStore = snapshot.secretKeyStore;
1167
- this.executionReceipts = snapshot.executionReceipts;
1168
- this.receiptIndex = snapshot.receiptIndex;
1169
- this.lastReceiptHash = snapshot.lastReceiptHash;
1170
+ this.ledger.restoreReceipts({
1171
+ receipts: snapshot.executionReceipts,
1172
+ index: snapshot.receiptIndex,
1173
+ lastReceiptHash: snapshot.lastReceiptHash,
1174
+ });
1170
1175
  this.policyBundles = snapshot.policyBundles;
1171
1176
  }
1172
1177
  // ========================================================================
@@ -1215,9 +1220,14 @@ class MemStorage {
1215
1220
  revocationChain: this.revocationChain,
1216
1221
  revokedPubkeys: Array.from(this.revokedPubkeys),
1217
1222
  lastRevocationHash: this.lastRevocationHash,
1218
- executionReceipts: this.executionReceipts,
1219
- receiptIndex: Array.from(this.receiptIndex),
1220
- lastReceiptHash: this.lastReceiptHash,
1223
+ ...(() => {
1224
+ const l = this.ledger.snapshotReceipts();
1225
+ return {
1226
+ executionReceipts: l.receipts,
1227
+ receiptIndex: Array.from(l.index),
1228
+ lastReceiptHash: l.lastReceiptHash,
1229
+ };
1230
+ })(),
1221
1231
  usedNonces: Array.from(this.usedNonces),
1222
1232
  secretKeyStore: Array.from(this.secretKeyStore),
1223
1233
  policyBundles: Array.from(this.policyBundles),
@@ -1263,9 +1273,11 @@ class MemStorage {
1263
1273
  this.revocationChain = s.revocationChain;
1264
1274
  this.revokedPubkeys = new Set(s.revokedPubkeys);
1265
1275
  this.lastRevocationHash = s.lastRevocationHash;
1266
- this.executionReceipts = s.executionReceipts;
1267
- this.receiptIndex = new Map(s.receiptIndex);
1268
- this.lastReceiptHash = s.lastReceiptHash;
1276
+ this.ledger.restoreReceipts({
1277
+ receipts: s.executionReceipts,
1278
+ index: new Map(s.receiptIndex),
1279
+ lastReceiptHash: s.lastReceiptHash,
1280
+ });
1269
1281
  this.usedNonces = new Map(s.usedNonces);
1270
1282
  this.secretKeyStore = new Map(s.secretKeyStore);
1271
1283
  this.policyBundles = new Map(s.policyBundles);
@@ -37,6 +37,7 @@
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
38
  exports.verifyConsistency = exports.verifySTH = exports.TransparencyLog = exports.verifyInclusion = exports.inclusionProof = exports.merkleRoot = exports.hashLeaf = void 0;
39
39
  const crypto_1 = require("crypto");
40
+ const ledger_store_1 = require("./ledger-store");
40
41
  const crypto_2 = require("./crypto");
41
42
  // ----------------------------------------------------------------------------
42
43
  // Merkle primitives (RFC 6962 domain separation: 0x00 leaves, 0x01 nodes)
@@ -111,8 +112,12 @@ function sthMessage(tree_size, root_hash, timestamp, witness_pubkey) {
111
112
  return `magenta-sth-v1|${tree_size}|${root_hash}|${timestamp}|${witness_pubkey}`;
112
113
  }
113
114
  class TransparencyLog {
115
+ /** Merkle leaves are a DERIVED view of the receipt sequence (rebuilt from
116
+ * receipts on boot when persistence is enabled) — they deliberately stay
117
+ * in-memory here rather than in the LedgerStore (architecture §2.2). */
114
118
  leaves = [];
115
- sthHistory = [];
119
+ /** STH checkpoint history lives behind the LedgerStore seam (PR 1). */
120
+ store;
116
121
  witnessPub;
117
122
  witnessSec;
118
123
  /**
@@ -120,10 +125,11 @@ class TransparencyLog {
120
125
  * or a second host). If omitted, a fresh independent key is generated — the
121
126
  * point is that it is NOT the trust root key.
122
127
  */
123
- constructor(witnessKey) {
128
+ constructor(witnessKey, store) {
124
129
  const kp = witnessKey ?? (0, crypto_2.generateKeyPair)();
125
130
  this.witnessPub = kp.publicKey;
126
131
  this.witnessSec = kp.secretKey;
132
+ this.store = store ?? new ledger_store_1.MemoryLedgerStore();
127
133
  }
128
134
  get witnessPublicKey() {
129
135
  return this.witnessPub;
@@ -133,7 +139,7 @@ class TransparencyLog {
133
139
  const leaf = hashLeaf(receiptHash);
134
140
  this.leaves.push(leaf);
135
141
  const sth = this.signTreeHead();
136
- this.sthHistory.push(sth);
142
+ this.store.appendSth(sth);
137
143
  return { index: this.leaves.length - 1, sth };
138
144
  }
139
145
  signTreeHead() {
@@ -146,7 +152,7 @@ class TransparencyLog {
146
152
  }
147
153
  /** The latest signed tree head (what you mirror to an external auditor). */
148
154
  latestSTH() {
149
- return this.sthHistory[this.sthHistory.length - 1];
155
+ return this.store.latestSth();
150
156
  }
151
157
  /** Inclusion proof for a given leaf index, against the current root. */
152
158
  proveInclusion(index) {
@@ -162,14 +168,35 @@ class TransparencyLog {
162
168
  size() {
163
169
  return this.leaves.length;
164
170
  }
171
+ /**
172
+ * Rebuild the DERIVED leaf view from replayed receipt hashes (file-ledger
173
+ * restart). Does NOT sign anything — the durable store already holds the
174
+ * committed STH history. Asserts the rebuilt view matches the latest
175
+ * committed checkpoint (boot-time corruption detection).
176
+ */
177
+ rebuildLeaves(receiptHashes) {
178
+ if (this.leaves.length > 0) {
179
+ throw new Error("transparency-log: rebuildLeaves on a non-empty log — refusing to overwrite the live view");
180
+ }
181
+ this.leaves = receiptHashes.map(hashLeaf);
182
+ const latest = this.store.latestSth();
183
+ if (latest && latest.witness_pubkey !== this.witnessPub) {
184
+ throw new Error("transparency-log: the durable ledger's STH history was signed by a DIFFERENT witness key than this " +
185
+ "process holds — a restart regenerated the witness identity. Pin MAGENTA_WITNESS_SECRET/MAGENTA_WITNESS_PUBLIC " +
186
+ "to the original key (witness continuity is required for origin verification); refusing to continue incoherently.");
187
+ }
188
+ if (latest && merkleRoot(this.leaves.slice(0, latest.tree_size)) !== latest.root_hash) {
189
+ throw new Error("transparency-log: rebuilt leaf view does not match the committed STH root — ledger/receipt divergence");
190
+ }
191
+ }
165
192
  /** Clear the log (fresh universe / test isolation). Keeps the witness key. */
166
193
  reset() {
167
194
  this.leaves = [];
168
- this.sthHistory = [];
195
+ this.store.clearSths();
169
196
  }
170
197
  /** For export/persistence: the raw leaves and STH history. */
171
198
  exportState() {
172
- return { leaves: this.leaves.slice(), sthHistory: this.sthHistory.slice(), witnessPub: this.witnessPub };
199
+ return { leaves: this.leaves.slice(), sthHistory: this.store.allSths(), witnessPub: this.witnessPub };
173
200
  }
174
201
  }
175
202
  exports.TransparencyLog = TransparencyLog;
@@ -18,6 +18,7 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.rebuildWitnessFromReceipts = exports.witnessReceipt = exports.resetWitnessLog = exports.witnessLog = void 0;
20
20
  const transparency_log_1 = require("./transparency-log");
21
+ const ledger_1 = require("./ledger");
21
22
  function loadWitnessKey() {
22
23
  const secretKey = process.env.MAGENTA_WITNESS_SECRET;
23
24
  const publicKey = process.env.MAGENTA_WITNESS_PUBLIC;
@@ -25,7 +26,7 @@ function loadWitnessKey() {
25
26
  return { publicKey, secretKey };
26
27
  return undefined;
27
28
  }
28
- exports.witnessLog = new transparency_log_1.TransparencyLog(loadWitnessKey());
29
+ exports.witnessLog = new transparency_log_1.TransparencyLog(loadWitnessKey(), ledger_1.sharedLedgerStore);
29
30
  /** Reset the witness log (fresh universe / test isolation). Keeps the witness key. */
30
31
  function resetWitnessLog() {
31
32
  exports.witnessLog.reset();
@@ -56,6 +57,13 @@ exports.witnessReceipt = witnessReceipt;
56
57
  function rebuildWitnessFromReceipts(receiptHashes) {
57
58
  if (exports.witnessLog.size() > 0)
58
59
  return; // already populated this process
60
+ if (exports.witnessLog.latestSTH() !== undefined) {
61
+ // Durable store already replayed committed STH history (file-ledger
62
+ // restart): rebuild ONLY the derived leaf view. Re-signing here would
63
+ // append duplicate checkpoints on every boot.
64
+ exports.witnessLog.rebuildLeaves(receiptHashes);
65
+ return;
66
+ }
59
67
  for (const h of receiptHashes) {
60
68
  try {
61
69
  exports.witnessLog.append(h);
@@ -0,0 +1,111 @@
1
+ # File Ledger — durable local evidence (`MAGENTA_LEDGER_FILE`)
2
+
3
+ The file ledger makes Magenta's evidence survive a process restart:
4
+
5
+ ```
6
+ action → receipt appended → write + fsync → "committed" returned
7
+ → restart → strict replay → same chain, same Merkle root, same STH history
8
+ → evidence still verifies (or startup FAILS LOUDLY)
9
+ ```
10
+
11
+ **Scope honesty:** single-writer, single-host, reference-grade durability for
12
+ operators running the lean gateway/control plane. It is NOT multi-tenant and
13
+ NOT hosted production infrastructure — that is the PostgreSQL ledger lane.
14
+
15
+ ## Modes
16
+
17
+ | Mode | Selection | Behavior |
18
+ |---|---|---|
19
+ | **Memory** (default) | no env var | historical behavior; evidence lives for the process lifetime |
20
+ | **File** | `MAGENTA_LEDGER_FILE=/absolute/path/magenta-ledger.jsonl` | one durable append-only ledger owns BOTH receipts and STH checkpoints, under one writer lock |
21
+
22
+ Rules:
23
+ - The path MUST be absolute. The parent directory must already exist (the
24
+ ledger never invents directories). Files are created `0600` where supported.
25
+ - A configured but unopenable ledger (bad path, lock held, corruption)
26
+ **fails startup**. There is no silent fallback to a blank in-memory universe.
27
+ - `MAGENTA_LEDGER_FILE` together with the legacy whole-state dump
28
+ (`MAGENTA_STATE_FILE`) is a **configuration error** — evidence has exactly
29
+ one durable authority. To migrate from a legacy state file: boot once with
30
+ only `MAGENTA_STATE_FILE` and export your evidence bundle, then start a
31
+ fresh file ledger and keep the legacy file as a read-only archive. (The
32
+ legacy dump remains supported for non-ledger state; it is simply never
33
+ combined with the file ledger.)
34
+ - **Pin the witness identity**: set `MAGENTA_WITNESS_SECRET` /
35
+ `MAGENTA_WITNESS_PUBLIC` when using file mode. A restart that regenerates
36
+ the witness key cannot continue a ledger signed under the old key — boot
37
+ refuses with a clear error. Durable receipts with a discontinuous witness
38
+ identity would break origin verification, so this is enforced, not advised.
39
+ - The demo (`npx magenta-canon demo`) keeps using a fresh in-memory universe.
40
+
41
+ ## File format (auditable with `less`)
42
+
43
+ Canonical JSON Lines, one record per line, 1 MiB line bound:
44
+
45
+ ```
46
+ {"v":1,"t":"header","genesis":"<64hex>","created":"<iso>"}
47
+ {"v":1,"t":"receipt","seq":1,"payload":{…receipt…},"payload_hash":"<canonicalHash(payload)>"}
48
+ {"v":1,"t":"sth","payload":{…signed tree head…},"payload_hash":"…"}
49
+ ```
50
+
51
+ Unknown versions or record types fail closed. Records are never rewritten,
52
+ re-sorted, repaired, or silently dropped.
53
+
54
+ ## The commit guarantee
55
+
56
+ A receipt or STH is reported committed only after its record is written and
57
+ `fsync`'d. The in-memory view advances only after the durable write succeeds.
58
+ A failed write surfaces an error, leaves state unchanged (retry is safe), and
59
+ never degrades to memory-only mode. Append enforces the ledger contract:
60
+ duplicate `receipt_hash` refused; `previous_receipt_hash` must equal the
61
+ committed tail; conflicting STH at an existing tree size refused
62
+ (equivocation), and the file ledger refuses destructive `clear*` operations.
63
+
64
+ ## Exclusive writer lock
65
+
66
+ `<ledger>.lock` is created atomically (`O_EXCL`) containing
67
+ `{pid, hostname, created, nonce, ledger}`. A second writer fails before
68
+ serving any traffic.
69
+
70
+ **Stale-lock recovery (deliberate operator action — never automatic):**
71
+ 1. Read the lock file; identify the holder pid/host/created.
72
+ 2. Verify that process is genuinely dead (`ps`, host check).
73
+ 3. Remove the lock file manually.
74
+ 4. Start the new process; replay validates the whole ledger before serving.
75
+
76
+ A malformed lock file also fails closed and is left in place for inspection.
77
+
78
+ ## Startup replay (strict)
79
+
80
+ Every committed record is re-validated: payload hash, receipt chain linkage,
81
+ `receipt_hash` recomputation, **issuer signatures**, duplicates, sequence
82
+ numbers, **STH signatures**, one-root-per-tree-size, **recomputed Merkle
83
+ roots**, and witness-key coherence. Any invalid committed record refuses
84
+ startup with the record number and reason. History is never repaired
85
+ automatically; never start a blank universe; never silently pick a backup.
86
+
87
+ ## Crash-tail policy (two distinct cases)
88
+
89
+ - **Incomplete final tail** (no terminating newline — provably never
90
+ committed, since commit = full line + `\n` + fsync): quarantined to
91
+ `<ledger>.tail-quarantine-<timestamp>` and truncated, with a loud audit
92
+ log line. Committed history is unaffected.
93
+ - **Complete but invalid record** (parses, but bad hash / broken chain /
94
+ bad signature / conflicting STH), anywhere including last: **corruption —
95
+ startup refuses.** No automatic truncation.
96
+
97
+ ## Backup
98
+
99
+ Copy the ledger file while the process is stopped (or accept that a live copy
100
+ may carry an uncommitted tail, which replay will quarantine). Restoring an
101
+ OLD backup re-exposes an earlier tree — external mirrors will flag the
102
+ regression; reconcile with your mirror operator before serving (see
103
+ `docs/STH_MIRROR.md`).
104
+
105
+ ## Limitations / path to PostgreSQL
106
+
107
+ Single writer; no tenant separation; locks are advisory at the filesystem
108
+ level; sync writes briefly block the event loop per append (fine at
109
+ reference-grade volumes). The hosted, transactional, lease-protected,
110
+ fail-closed implementation is the PostgreSQL ledger lane
111
+ (`docs/roadmaps/DURABLE_WITNESS_ARCHITECTURE.md`).
@@ -5,7 +5,7 @@ what deliberately does not, and how to verify the package locally.
5
5
 
6
6
  > **Status: published on npm.** The current published release is **`0.1.13`**,
7
7
  > carried on **both** the `latest` and `next` dist-tags (CJ published `0.1.13`
8
- > and explicitly aligned both tags). This refresh prepares **`0.3.0`** — the
8
+ > and explicitly aligned both tags). This refresh prepares **`0.4.0`** — the
9
9
  > **truth-hardened verifier** (spec v1.1): layered verdicts (`ORIGIN AND
10
10
  > INTEGRITY VERIFIED` / `INTEGRITY VERIFIED` / `INCOMPLETE EVIDENCE` /
11
11
  > `VERIFICATION FAILED`), independently pinned witness keys
@@ -56,7 +56,9 @@ npm dist-tag add magenta-canon@<version> next
56
56
  | `0.1.12` | the **full trust loop** release: external STH mirror (`mirror` CLI: append/verify/check/--self-test) + mirror-feed operator helper (`mirror-feed`) + operational runbook; supply-chain remediation (`npm audit` 15→**0**, runtime deps 19→17, `ws` removed, `@anthropic-ai/sdk` demoted to dev with a lazy key-gated bridge, express transitives patched via overrides, drizzle-orm 0.45.2, tarball narrowed); intake/gateway test-hygiene; plus the Windows ESM subprocess-path fix + first Windows CI job — **published** |
57
57
  | `0.1.13` | **security hardening** (no API change): constant-time `INTERNAL_API_KEY` comparison; trust-key custody regression tests (root/intermediate never transit, actor key never persisted, key-returning routes fail closed) pinning the design Socket's AI heuristic flagged on `server/routes.ts`; threat-model docs (`SECURITY_MODEL.md` "Trust-key custody") + Socket dependency-alert mapping (`SUPPLY_CHAIN_REVIEW.md` §8) |
58
58
  | `0.2.0` | the **lean precompiled artifact** (supply-chain cleanroom, PRs #23–#25): minimal in-process demo control plane (the hosted Express/Passport/Postgres plane no longer ships); explicit `files` whitelist; org-storage and org-schema splits keep `pg`/drizzle out of the lean closure; **precompiled CommonJS `dist/`** built by `npm run build:lean` at `prepack` (deterministic, sha256 `MANIFEST.json`); runtime deps 17 → **2** (`tweetnacl`, `zod`); consumer install 111 → **3** packages; **zero install scripts**; no runtime `tsx`/`esbuild`/TypeScript; closure + precompile regression guards in `scripts/lean-closure.test.ts` — **published**; `latest`/`next` aligned |
59
- | `0.3.0` | **verifier truth hardening** (spec v1.1, PR #27): layered verdicts; `--expected-witness-key` (independently pinned witness identity — a bundle's own key can no longer masquerade as trusted); `INCOMPLETE EVIDENCE` for zero-receipt bundles; `--require-receipt`/`--require-action-hash`/`--min-receipts` claim pinning; **receipt issuer signatures enforced**; `--json` machine output; 21-test adversarial battery; demo now pins the witness key and earns `ORIGIN AND INTEGRITY VERIFIED`. Plus: Durable Witness architecture report, MVAR v1 receipt-standard DRAFT + schema/vectors, public verifier-page design — **not yet published** |
59
+ | `0.3.0` | **verifier truth hardening** (spec v1.1, PR #27): layered verdicts; `--expected-witness-key` (independently pinned witness identity — a bundle's own key can no longer masquerade as trusted); `INCOMPLETE EVIDENCE` for zero-receipt bundles; `--require-receipt`/`--require-action-hash`/`--min-receipts` claim pinning; **receipt issuer signatures enforced**; `--json` machine output; 21-test adversarial battery; demo now pins the witness key and earns `ORIGIN AND INTEGRITY VERIFIED`. Plus: Durable Witness architecture report, MVAR v1 receipt-standard DRAFT + schema/vectors, public verifier-page design — **published**; `latest`/`next` aligned |
60
+ | `0.4.0` | **durable file ledger** (Durable Witness PRs 1–2, #32/#33): `LedgerStore` seam; `FileLedgerStore` — append-only canonical-JSONL evidence ledger (`MAGENTA_LEDGER_FILE`), fsync-before-committed, exclusive writer lock with documented stale-lock recovery, strict boot replay (payload hashes, chain, issuer signatures, STH signatures, one-root-per-tree-size, recomputed Merkle roots), crash-tail quarantine vs corruption refusal, **witness-continuity enforcement** (regenerated witness identity refuses to continue an existing ledger), dual-authority prevention vs legacy `MAGENTA_STATE_FILE`; two-real-process restart proof (`ORIGIN AND INTEGRITY VERIFIED` after `SIGKILL`); ships `docs/FILE_LEDGER.md` — **not yet published** |
61
+
60
62
 
61
63
  **Verified on macOS / Linux / Windows (current release `0.1.11`; `0.1.12` re-proven via installed-tarball smoke):**
62
64
  `npx magenta-canon demo` completes the full
@@ -254,21 +256,21 @@ MCP gateway, and a **local-file external STH mirror foundation** with an
254
256
  operator runbook. **Not** included: hosted mirror service, automated network
255
257
  publishing, production multi-tenant SaaS, production durability guarantees.
256
258
 
257
- ## Verifying the release artifact (checklist — current target `0.3.0`)
259
+ ## Verifying the release artifact (checklist — current target `0.4.0`)
258
260
 
259
261
  From a clean checkout of the release commit:
260
262
 
261
263
  ```bash
262
264
  npm ci && npm run check && npm test # 0 tsc errors; full suite green
263
265
  npm run build:lean && npm run build:lean # deterministic: dist/MANIFEST.json identical
264
- npm pack # prepack rebuilds dist/; expect magenta-canon-0.3.0.tgz
265
- tar -tzf magenta-canon-0.3.0.tgz # ~50 files; no .ts, no .map, no tests, no server/routes.ts
266
+ npm pack # prepack rebuilds dist/; expect magenta-canon-0.4.0.tgz
267
+ tar -tzf magenta-canon-0.4.0.tgz # 53 files; no .ts, no .map, no tests, no server/routes.ts, no ledger/lock files
266
268
  ```
267
269
 
268
270
  Then in an empty directory:
269
271
 
270
272
  ```bash
271
- npm init -y && npm i ../path/to/magenta-canon-0.3.0.tgz
273
+ npm init -y && npm i ../path/to/magenta-canon-0.4.0.tgz
272
274
  ls node_modules # exactly: magenta-canon tweetnacl zod
273
275
  npx magenta-canon verify --self-test # all verdict levels incl. tamper VERIFICATION FAILED
274
276
  npx magenta-canon mirror --self-test # [PASS] x3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magenta-canon",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "A verifiable MCP accountability gateway for AI-agent tool calls: allows authorized calls, blocks unauthorized calls, records both, and produces cryptographic evidence anyone can verify.",
@@ -43,6 +43,7 @@
43
43
  "docs/MCP_GATEWAY.md",
44
44
  "docs/SECURITY_MODEL.md",
45
45
  "docs/NPM_PACKAGING.md",
46
+ "docs/FILE_LEDGER.md",
46
47
  "public/canon/schemas/constitutional-spine.schema.json",
47
48
  "public/canon/spine/constitutional-spine.v1.json",
48
49
  "README.md",