{"slug": "our-ledger-balanced-perfectly-and-had-no-audit-trail", "title": "Our ledger balanced perfectly and had no audit trail", "summary": "A developer released MnemoPay 1.15, an open-source Apache 2.0 TypeScript SDK that adds policy checks, human approvals, a double-entry ledger and tamper-evident audit chains for AI agents taking consequential actions. The release fixes a bug in 1.14.2 where calling Ledger.compact(n) with n <= 100 permanently disabled entry recording, leaving ledgers that balanced and passed hash-chain verification but had no audit trail; 1.15 makes compact() trim-only with an archiveSink, reports audit detail via verify(), and widens entry hashes to cover every field. The SDK also introduces an OnChainAnchorSink interface for anchoring Merkle roots externally, though the developer notes the chain proves internal consistency, not that a record existed at a given time.", "body_md": "If an AI agent moves money or changes infrastructure, someone will eventually ask: what exactly happened, and can you prove it? Not \"do the totals add up\", but \"show me this transaction, and show me nobody edited the record afterwards\".\n\nMnemoPay is an open-source (Apache 2.0) TypeScript SDK for that layer: policy checks, human approvals, a double-entry ledger, and tamper-evident audit chains for agents that take consequential actions. Version 1.15 is out (current: 1.15.2). The most interesting part of it is a bug in our own code.\n\n`Ledger.compact(n)` trims old entries so memory stays bounded. In 1.14.2, calling it with `n <= 100` also switched entry recording off permanently. The SDK's own `MnemoPayLite` calls `compact(50)`, so after roughly ten settlements (per the changelog) the ledger kept correct running totals but stopped recording entries at all.\n\nNothing looked wrong. The totals balanced. The hash chain \"verified\". There was just nothing left to audit. Here is the same loop against both versions (I used `Ledger` directly and called `compact(50)` the way `MnemoPayLite` does):\n\n``` js\nimport { Ledger } from \"@mnemopay/sdk\";\nconst ledger = new Ledger();\nledger.recordFunding(\"agent-1\", 1000, \"stripe-topup\");\nfor (let i = 0; i < 20; i++) {\n  ledger.recordCharge(\"agent-1\", 10, `tx-${i}`);\n  ledger.recordSettlement(\"agent-1\", `tx-${i}`, 10, 0.19, 9.81, \"merchant-1\");\n  if (i % 5 === 4) ledger.compact(50);\n}\nconst s = ledger.verify();\nconsole.log({ balanced: s.balanced, entryCount: s.entryCount,\n  entriesForLastTx: ledger.getEntriesForTransaction(\"tx-19\").length,\n  auditTrail: s.auditTrail ?? \"(field not present)\", chainValid: s.chainValid });\nsdk 1.14.2   { balanced: true, entryCount: 162, entriesForLastTx: 0, auditTrail: '(field not present)', chainValid: true }\nsdk 1.15.0   { balanced: true, entryCount: 162, entriesForLastTx: 8, auditTrail: 'compacted', chainValid: true }\n```\n\nOn 1.14.2 the last transaction has zero entries behind it, while every check we had said \"fine\". In 1.15.0, `compact()` only trims, keeps the hash-chain link, and hands dropped entries to an `archiveSink` you can point at durable storage. `verify()` now says how much detail backs the totals (`complete`, `compacted` or `disabled`), and turning the trail off is an explicit `setHighThroughput(true)`. We also widened the entry hash to cover every field, so editing a description or timestamp is now detected, and `recordSettlement()` rejects fee plus net that does not equal gross before it writes any leg.\n\nIf you are on 1.14.x, upgrade. (1.15.1 and 1.15.2 are small follow-ups: `require()` for subpath imports, the right version in the MCP server, and scripts that now exit on their own.) Ledgers saved by earlier versions still verify.\n\nThe audit chain turns events into a Merkle root. Change one event afterwards and the root no longer matches:\n\n``` js\nimport { AuditChain, verifyBundle } from \"@mnemopay/sdk/governance\";\nconst chain = new AuditChain();\nchain.emit(\"action.proposed\",  { agent: \"refund-bot\", action: \"refund\", amountUsd: 40 });\nchain.emit(\"approval.granted\", { approver: \"ops@example.com\" });\nchain.emit(\"action.executed\",  { txId: \"tx-1001\", amountUsd: 40 });\nconst bundle = chain.toBundle({ system: \"demo\" });\nconsole.log(\"untouched:\", verifyBundle(bundle));\nconst forged = structuredClone(bundle); forged.events[2].payload.amountUsd = 4;\nconsole.log(\"edited:   \", verifyBundle(forged));\nuntouched: { ok: true }\nedited:    { ok: false, reason: 'root_mismatch' }\n```\n\nThe limit is worth saying plainly: whoever holds the bundle can recompute a root, so this proves internal consistency, not that the record existed at a given time.\n\nThat gap is what the new `OnChainAnchorSink` interface is for. A provider implements `anchor()` and `status()` in their own package; the SDK hands over only the Merkle root and the metadata you choose, never the events, and stores the returned reference next to the bundle. The SDK ships the interface, helpers and a `MockAnchorSink`, with no chain code, ABIs or gas handling:\n\n``` js\nimport { AuditChain, verifyBundle, anchorChainBundle, pollAnchor,\n         attachAnchorReference, MockAnchorSink } from \"@mnemopay/sdk/governance\";\nconst chain = new AuditChain();\nchain.emit(\"action.executed\", { txId: \"tx-1001\", amountUsd: 40 });\nconst bundle = chain.toBundle({ system: \"demo\" });\nconst sink = new MockAnchorSink({ pendingTicks: 2 });\nconst { anchorId } = await anchorChainBundle(bundle, sink, { producerId: \"demo-producer\" });\nconst status = await pollAnchor(sink, anchorId, { delayMs: 0 });\nconst { state, ...ref } = status;\nconsole.log(status.chain, \"| fields sent:\", Object.keys(sink.requests[0]).join(\", \"));\nconsole.log(\"still verifies:\", verifyBundle(attachAnchorReference(bundle, ref)));\nmock | fields sent: producerId, merkleRoot, bundleMeta, sealedAt\nstill verifies: { ok: true }\n```\n\nNote `chain` is `\"mock\"`. That is deliberate, so a mock reference can never pass for a real attestation. What a real anchor proves depends on the provider: the changelog notes that one candidate contract does not authenticate the submitter, so it would show a root existed at block T, not who committed it.\n\nThe SDK can export an \"Article 12 bundle\" for a finished mission: mission metadata, every event, a CSV, a hash chain and a manifest with checksums, with a default retention field of 6 months. That is evidence in a regulator-readable shape. It is not legal compliance, and the repo's own EU AI Act technical file says it is self-prepared with no auditor engaged. Whether your system is high-risk, and whether this satisfies you, is for you and your counsel.\n\n`0xdb8b69be…e213`, block 53374272, `verifyBundle` recomputes for the fixture. It shows that root existed at that block, not who committed it. It is a test fixture, not a customer.`mnemopay` on PyPI, 1.3.0) is a separate, smaller port.\n\n```\nnpm install @mnemopay/sdk\n```\n\nRepo and changelog: [https://github.com/mnemopay/mnemopay-sdk](https://github.com/mnemopay/mnemopay-sdk). Issues welcome, especially from anyone who audits agent systems for a living.", "url": "https://wpnews.pro/news/our-ledger-balanced-perfectly-and-had-no-audit-trail", "canonical_source": "https://dev.to/t49qnsx7qtkpanks/our-ledger-balanced-perfectly-and-had-no-audit-trail-4cbl", "published_at": "2026-11-04 03:42:14+00:00", "updated_at": "2026-11-04 03:55:54.463900+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "ai-tools"], "entities": ["MnemoPay", "MnemoPayLite", "Ledger", "AuditChain", "OnChainAnchorSink", "MockAnchorSink"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/our-ledger-balanced-perfectly-and-had-no-audit-trail", "markdown": "https://wpnews.pro/news/our-ledger-balanced-perfectly-and-had-no-audit-trail.md", "text": "https://wpnews.pro/news/our-ledger-balanced-perfectly-and-had-no-audit-trail.txt", "jsonld": "https://wpnews.pro/news/our-ledger-balanced-perfectly-and-had-no-audit-trail.jsonld"}}