{
  "markdown": "# arcaeon-ledger\n\n<!-- mcp-name: io.arcaeon/ledger -->\n\n**Observability tools show you what your agent did. `arcaeon-ledger` lets you _prove_ it.**\n\nEvery record is hash-chained to the one before it. Edit a row, delete one, or\nreorder history, and every later link breaks — `verify` names the exact line.\nYou own the record, and you can prove it wasn't altered. Zero dependencies, one\nJSONL file, two verbs.\n\n```\npip install arcaeon-ledger      # then:  from arcaeon_ledger import Ledger\n```\n\n```python\nfrom arcaeon_ledger import Ledger\n\nlog = Ledger(\"agent.log.jsonl\")\nlog.append({\"tool\": \"web.search\", \"query\": \"weather in LA\", \"result_ok\": True})\nlog.append({\"tool\": \"payment\", \"amount\": \"49.00\", \"currency\": \"USD\"})\n\nlog.verify()          # VerifyResult(ok=True, rows=2, chained=2, ...)\n```\n\nTampering is caught, not hoped against:\n\n```python\n# someone edits row 2's amount in the file by hand...\nlog.verify()          # VerifyResult(ok=False, first_break=\"line 2: chain mismatch\")\n```\n\nCLI (wire it into CI or a pre-ship gate — a tampered log exits nonzero, and a\nlog that could only be *partially* vouched for no longer exits like a fully\nverified one):\n\n```\npython -m arcaeon_ledger.cli append agent.log.jsonl '{\"tool\":\"search\",\"ok\":true}'\npython -m arcaeon_ledger.cli verify agent.log.jsonl\npython -m arcaeon_ledger.cli verify --strict agent.log.jsonl\n```\n\n`verify` exit codes (0.5.7):\n\n| exit | meaning |\n|------|---------|\n| `0`  | fully verified — every row checked, chain intact (`ok: true`) |\n| `1`  | broken — a break was found (`ok: false`), or bad usage |\n| `3`  | verified **within scope** only (`ok: null` in every case): no break found, but unchained `prechain` rows were skipped unverified (`verified_scope: \"bounded_prechain_skipped\"`); or the only breaks are declared ones (`\"bounded_declared_break\"`); or the file has zero rows (`\"empty\"`). A fabricated \"legacy\" prepend lands here, never at 0. Pass `--strict` to make the first two a hard `1` instead. |\n\nA CI gate should treat only `0` as green:\n\n```sh\npython -m arcaeon_ledger.cli verify agent.log.jsonl\ncase $? in\n  0) echo \"fully verified\" ;;\n  3) echo \"chain intact but prechain rows skipped unverified — inspect, or use --strict\" ; exit 1 ;;\n  *) echo \"ledger broken\" ; exit 1 ;;\nesac\n```\n\n## Prove *who* acted, not just the order\n\nA hash chain proves sequence integrity — it can't prove who wrote each entry or\nwhether they were allowed to. Attach an `authority` block to bind the actor and\ntheir permission surface into the chained (tamper-evident) row:\n\n```python\nfrom arcaeon_ledger import Ledger, authority\n\nlog = Ledger(\"agent.log.jsonl\")\nlog.append(\n    {\"tool\": \"payment\", \"amount\": \"49.00\"},\n    authority=authority(\n        \"agent://billing-7\",\n        capability_version=\"v3\",              # what they were allowed to do\n        tool_schema={\"name\": \"payment\", \"args\": [\"amount\"]},  # hashed, not just named\n        time_source=\"ntp\",                    # trust surface of the clock\n    ),\n)\n```\n\nNow the audit question sharpens from *\"was this edited?\"* to *\"was this edited\n**and** was the writer authorized?\"* — editing the principal, capability, or\nschema hash breaks the chain like any other tamper. This composes tamper-evidence\nwith permission-replay. (Shipped in response to community feedback on launch.)\n\n## Why this exists\n\nThe loudest unmet pain for agent builders in 2026 is the reliability/audit gap:\nan agent \"completes\" a task and the result is quietly wrong, and you can't\nreconstruct — or prove — what actually happened. Observability platforms trace\nruns; none give you a **tamper-evident, portable, ownable** record. Regulation\nis arriving too: the EU AI Act requires high-risk systems to technically allow\nautomatic recording of events over their lifetime (Art. 12(1)) and requires\nproviders and deployers to keep those logs, to the extent under their control,\nfor at least six months (Art. 19(1), Art. 26(6)). The Act mandates recording\nand retention — tamper-evidence is not its word, it is ours: when someone asks\nwhether a retained log is still the log, that question needs an answer stronger\nthan trust. `arcaeon-ledger` is the smallest honest version: a cryptographically\nchained action log you drop in, own, and verify.\n\n## How the chain works\n\n`chain = sha256(prev_chain + json.dumps(row_without_chain, sort_keys=True, ensure_ascii=False))[:32]`\n\nNote the separators: the chain body uses Python's default `\", \"` / `\": \"`\nspacing, not the compact `json-c14n:v1` form the artefact digests use. A\ncross-language verifier has to reproduce that spacing exactly.\n\nThe chain value is **`truncated_sha256_128`** — the first 32 hex chars (128 bits)\nof SHA-256, not the full digest. Named so nobody cites it as full SHA-256:\n128 bits is plenty for edit/accident detection, thinner if you want the chain\nitself to be expensive to grind after a rewrite (credit: atomic-raven's review).\n\nEach row commits to the entire history before it. The first row chains from a\nfixed `\"genesis\"` seed. Rows without a `chain` field are tolerated only before\nthe first chained row (so you can adopt it on an existing log); an unchained row\nappearing *after* the chain begins is itself flagged. On a mismatch, verify\nkeeps going from the claimed value so it counts later damage honestly instead of\ncascading one break into noise.\n\n## What it proves — and the five things it doesn't\n\nBeing precise here is the product, not a disclaimer. A hash chain proves the\nrecorded *content* of each row was not altered **in place** after writing:\nmid-file edit, delete, and reorder all break it and `verify` names the row.\n\nOne word in that sentence changed in 0.5.8, and the reason is the kind of thing\nthis section exists for. It used to say \"the recorded **bytes**\", which claims\nmore than the chain does. The chain is computed over each row parsed back from the\nfile, and the reader normalises byte sequences it cannot decode — so two different\nbyte strings inside such a region read identically and produce the same verdict.\nWhat is protected is the meaning of every row, not the exact bytes of the file. If\nyou need byte-level custody, hash the file itself alongside this.\n\nIt does **not** by itself prove five other things:\n\n**1. Truncation.** Lop off the most recent rows and what remains verifies clean —\nno append-only chain catches this alone. Close it by publishing the head somewhere\noutside your own control, on a cadence:\n\n```python\npin = log.head().as_pin()\n# -> \"arcaeon-ledger head chain=9f3c… rows=204 as_of=2026-08-13T17:40:00Z\"\n# post `pin` to a git commit / public comment / notarization anchor.\n# a reader compares a fresh head() against the last pin; a truncated or\n# re-minted history disagrees. the MAX gap between pins is your security\n# parameter, not the average — an attacker picks the gap.\n```\n\n**2. Truth.** The chain notarizes whatever was written — a tamper-evident record\nof a hallucination is still a hallucination with a checksum. To make a row speak\nabout the world, hash a re-fetchable artefact (URL+bytes, a snapshot, tool stdout)\nand store that digest in the row, so a third party can re-get it and compare.\n\n**3. Authorship.** `authority()` (above) records who-claimed-what, but it is data\nin the row, not a signature — a rewriter who re-mints from genesis re-mints it too.\nExternal head-anchoring (#1) is the thing a re-minter cannot advance.\n\n**4. Fabricated-legacy-prepend.** Rows with no `chain` field are tolerated *before*\nthe first chained row — that is deliberate, so you can adopt the chain on top of an\nexisting log without rewriting its history. But skipped rows are *unverified* rows,\nand the verifier cannot tell real legacy history from a fabricated prepend. So\n(0.5.7) a non-strict verify that skipped any rows never mints a green: `ok` is\n`None` — \"no break found, verified within scope\" — falsy, with the scope in-band\n(`verified_scope: \"bounded_prechain_skipped\"`) and the count in `prechain`; the CLI\nexits `3`, not `0`. Only a scan that checked every row returns `ok=True`. If your\nlog is chained from genesis and must have no legitimate legacy rows, pass\n`verify(strict=True)` / `--strict` — it treats any unchained row as a break, hard\nred. (An unchained row inserted *after* the chain begins is already flagged in\nevery mode.)\n\n**5. Completeness.** This is the big one, and it is structural: the agent decides\nwhat to call `append` on. A tamper-evident log of the calls an agent *chose to\nreport* is still self-report. Nothing inside this library can close that, because\nanything the agent invokes, the agent can decline to invoke.\n\nClose it by moving the pen out of the agent's reach — record at the seam instead,\nin a separate OS process the agent does not own, cannot skip, and cannot see:\n\n```\npip install arcaeon-adapter\n\npython -m arcaeon_adapter --ledger seam.log.jsonl -- <your mcp server command...>\n```\n\n[`arcaeon-adapter`](https://github.com/dan8433-user/ledger/tree/main/adapter) is a\nstdio proxy that forwards JSON-RPC byte-for-byte between an MCP client and server,\nwriting one hash-chained row per `tools/call` to its own ledger. Wrapping it around\n*this* library's own MCP server produced the number that makes the point: the\nserver's own diary wrote **0 rows** while the seam log captured **5**. The gap\nbetween what a system reports about itself and what the seam observed is the\nthing worth measuring.\n\nScoped honestly, the primitive is *\"this file was not rewritten in place\"* — small,\ntrue, and testable. The layers above (external anchoring via `head()`, artefact\nbinding, signed authorship, seam recording) are how you extend it toward a full\nevidence claim.\n\n### verify() on missing or empty ledgers\n\nThe two look like the same thing (\"no data\"), and `verify()` keeps them\napart, on purpose:\n\n```python\nLedger(\"never/written.jsonl\").verify()\n# VerifyResult(ok=False, rows=0, first_break=\"unreadable: [Errno 2] No such file...\")\n\nopen(\"touched/empty.jsonl\", \"w\").close()\nLedger(\"touched/empty.jsonl\").verify()\n# VerifyResult(ok=None, rows=0, chained=0, first_break=None, verified_scope=\"empty\")\n```\n\nA path that was never created can't be vouched for — `ok=False`, \"unreadable,\"\nsame as any other read failure. A path that exists and is genuinely empty has\nzero rows to tamper with, but zero rows checked is not a green either (since\n0.5.8): `ok=None, rows=0, verified_scope=\"empty\"`, falsy, CLI exit 3. Automation\nthat branches on `verify().ok` gets a red for the missing file and a\nnot-a-pass for the empty one; read `first_break` and `verified_scope` to tell\nthe two apart by name.\n\n### When the log was written out of band: declare the break, don't re-forge it\n\nSooner or later something writes to your JSONL without going through `append()` —\na script, an incident, a person with an editor. The chain breaks there and stays\nbroken, because that is the true record. Your two obvious options are both bad:\nlive with a permanent red that tells a reader nothing, or recompute the chain so\nthe file goes green — which is forging it, and a chain you can silently re-forge\nis not evidence of anything.\n\n`declare_break` is the third option. It **appends** a row naming the break:\n\n```python\nfrom arcaeon_ledger import declare_break, verify_file\n\ndeclare_break(\"agent.log.jsonl\", 25,\n              \"Written out of band 2026-08-15 by a session hand-appending JSON \"\n              \"instead of calling append(). Content is true and preserved verbatim; \"\n              \"no chain value was ever computed for it, so none can honestly be supplied.\")\n\nr = verify_file(\"agent.log.jsonl\")\nr.ok               # None  — bounded, NOT True. Falsy.\nr.verified_scope   # \"bounded_declared_break\"\nr.breaks           # 0\nr.declared         # [\"line 25: declared break (Written out of band 2026-08-15 ...)\"]\n```\n\nThe break stays a break, forever, in `declared`. What changes is that a known,\nexplained break stops masquerading as an unexplained one — and the orphan's exact\nbytes are pinned by sha256, so editing that line afterwards turns the file red\nagain. **It never returns `ok=True`.** Only a scan that checked every row does\nthat, and an excused row was not checked. `verify(strict=True)` ignores\ndeclarations entirely.\n\n**What this does not do, said plainly: it is a record device, not a\ncryptographic one.** Anyone who can write the file can write a declaration, so it\nraises no bar at all against an attacker who already has write access. It defends\nagainst *forgetting*, not against tampering. It cannot tell an honest out-of-band\nappend from a malicious one — `why` is an unverified human sentence. And it can\nonly declare breaks `verify()` already found; it does nothing about breaks nobody\nnoticed. Use it to keep an honest incident legible, never as a way to make a\nledger green.\n\n## Bind what the agent actually read (artefact-binding)\n\nThe chain proves a row wasn't edited. It does **not** prove the row was ever *true* —\nit will notarize a hallucination as faithfully as a fact. `bind_artefact` closes\nthat gap for the cases where you can point at a re-fetchable source: hash the actual\nbytes the agent read and store that digest *in* the row, so a third party can\nre-get the source and compare.\n\n```python\nfrom arcaeon_ledger import Ledger, bind_artefact\n\nlog = Ledger(\"agent.log.jsonl\")\nart = bind_artefact(\"https://example.com/pricing\")   # or bytes, a file path, or a dict\nlog.append({\"tool\": \"web.read\", \"url\": \"https://example.com/pricing\", \"artefact\": art})\n# art -> {\"subject\": {\"name\": \"...\", \"digest\": {\"sha256\": \"...\"}},\n#         \"recipe\": \"sha256:raw-bytes:v1\",\n#         \"digest\": \"sha256:raw-bytes:v1:<hex>\", \"bound_at\": \"...\", \"source_meta\": {...}}\n```\n\nDigests are **self-describing** — never a bare hex hash. Each one is\n`sha256:<recipe>:<version>:<hex>`, carrying its own recipe so a stranger reproduces\nit from the string alone: `raw-bytes:v1` (opaque bytes as-read) or `json-c14n:v1`\n(a pinned, documented JSON canonicalization — sorted keys, compact, UTF-8). Recipes\nare frozen and versioned append-only, so old rows keep their recipe forever and a\nchanged rule never makes history look tampered.\n\nVerify honestly:\n\n```python\nfrom arcaeon_ledger import verify_artefact\n\nverify_artefact(art)                    # recipe reproducible + string self-consistent\nverify_artefact(art, refetch=True)      # for a URL: re-fetch and compare\n# -> {\"verdict\": \"live_match\",          # <- THE answer; read this field\n#     \"digest_ok\": True, \"reason\": None,\n#     \"refetch\": \"match\" | \"mismatch\" | \"unavailable\" | \"skipped\", \"notes\": [...]}\n```\n\n**Read `verdict`, not just `digest_ok` (0.5.7).** `digest_ok` names only the\n*offline* leg — recipe reproducible, string self-consistent — and it stays `True`\neven when a live re-fetch disagrees. The top-level `verdict` tag mints the whole\nanswer in one field: `\"digest_consistent\"` (offline leg passed, no live comparison\nmade), `\"live_match\"`, `\"live_mismatch\"` (live content no longer matches —\nchanged *or* tampered, indeterminate), `\"live_unavailable\"` (the requested live\ncheck could not run), or the typed failure reason itself when the offline leg\nfails. `if out[\"digest_ok\"]` after `refetch=True` used to read green through a\nlive mismatch; `out[\"verdict\"] == \"live_match\"` cannot.\n\n**A label this build cannot reproduce is a typed failure, never a pass.** If the\ndigest names an algorithm, recipe, or recipe *version* outside the supported\nregistry, `verify_artefact` returns `digest_ok=False` with a machine-readable\n`reason` — one of `unknown_algorithm`, `unknown_recipe`, `unknown_recipe_version`,\n`malformed_digest`, `subject_digest_mismatch` — and never reaches the re-fetch\nstage, so an unverifiable recipe can't come back as `\"match\"`. A digest we cannot\nrecompute is a digest we did not check, and \"did not check\" must not be reported as\n\"verified.\" Old versions stay verifiable by staying listed in\n`SUPPORTED_RECIPE_VERSIONS` when a new one is minted, so the append-only recipe\npromise holds without the verifier waving through labels it has never shipped.\n\n**The honest boundary, stated loudly because it is the point:** a re-fetch\n`mismatch` means the content *changed or* was tampered — **indeterminate**. It is\nnever reported as proof of tampering. The web mutates, 404s, paywalls, and\npersonalizes; binding proves *\"this is the digest of the bytes the agent said it\nread at time T,\"* nothing stronger. For a neutral capture rather than your own\nfetch, route the source through a notarizing snapshot; for *existed-before-T*, anchor\nthe digest externally. Each is a layer you add — stated, not implied.\n\n## The outside check: an external witness\n\nThe chain can't catch truncation alone — lop off the most recent rows and what\nremains verifies clean (stated in \"what it doesn't prove\", above). The fix is a\n**witness**: a record-keeper outside your own control that holds your head\n`(rows, chain)` on a cadence. Once a witness has a pin from time T, a truncated\nlog has *fewer rows* than the witness saw, and a rewritten one has a *different\nchain* at the witnessed row. Neither can hide.\n\n```python\nfrom arcaeon_ledger import Ledger, WitnessStore, publish_head, verify_against_witness\n\nlog = Ledger(\"agent.log.jsonl\")\nwitness = WitnessStore(\"witness_pins.jsonl\")   # ideally on a host you don't control\n\npublish_head(witness, \"billing-agent\", log)    # record the current head — do this on a cadence\n\n# later — did the log survive intact?\nv = verify_against_witness(witness, \"billing-agent\", log)\nv.verdict     # \"consistent\" | \"truncated\" | \"rewritten\" | \"no_record\" | \"witness_broken\" | \"local_broken\"\nbool(v)       # truthy ONLY on \"consistent\" — a missing pin is no_record, never a false ok\n\n# READ THE VERDICT WITH ITS QUALIFIERS, never the bare string alone:\nv.witness_self_integrity   # \"verified\" | \"unestablished\" | \"broken\"\n```\n\n**A bare `\"consistent\"` is not the whole answer.** The verdict also carries\n`witness_self_integrity`: whether the witness store could prove its *own* pin\nchain intact. A hosted client that only exposes `latest()` cannot self-verify,\nso its verdicts read `unestablished` — the comparison ran honestly, but a\nforged pin *served by that store* would compare clean. `verified` means the\nstore's own chain was recomputed; `broken` means it failed. A consumer that\nbranches on `v.verdict == \"consistent\"` without reading\n`witness_self_integrity` is trusting the store's honesty exactly as much as it\nwould trust the log's — which is the arrangement a witness exists to replace.\n(Found in the 2026-08-23 pre-invite audit, C14; the field exists so \"not\nchecked\" can never render as \"checked and fine.\")\n\n`WitnessStore` is the reference witness: one append-only JSONL file of pins. A\nhosted witness is a thin HTTP wrapper over exactly this object; run it locally\nand you have a complete, offline, zero-cost witness you fully control (with the\nobvious caveat that a witness you control is only as independent as its host).\n\n**What this proves, exactly.** A witness proves your log wasn't truncated or\nrewritten *only relative to what the witness saw, and only as recently as the\nlast pin*. Rows appended after the last pin are unprotected until the next one —\nso **the MAX gap between pins is your real security parameter, not the average,\nbecause an attacker picks the gap.** And it says nothing about whether the logged\ncontent was *true* — that's artefact-binding's job (above); the witness only\nguards the history's shape.\n\n**What the witness holds.** Only fingerprints — `(namespace, rows, chain, time)` —\nnever your log content. Password-nowhere by design: if the witness is breached,\nthere is nothing sensitive to steal, only hashes useless without the original log.\n\n## Drop it into any MCP agent\n\n`arcaeon-ledger` ships a zero-dependency MCP server, so any MCP client (Claude Code,\netc.) can give its agent tamper-evident logging with no code. Wire it in:\n\n```json\n{\n  \"mcpServers\": {\n    \"ledger\": {\n      \"command\": \"python\",\n      \"args\": [\"-m\", \"arcaeon_ledger.mcp_server\", \"--log\", \"agent.log.jsonl\"]\n    }\n  }\n}\n```\n\nThe agent then has five tools. Two are **operator tools** over one file:\n`ledger_append(record)` to log an action (returns its chain hash) and\n`ledger_verify(strict?)` to prove the log is intact (or get the exact tampered\nline back). The verify verdict is three-valued, same as the library:\n`ok: true` = every row verified, `ok: null` = chain intact but unchained\n`prechain` rows were skipped unverified\n(`verified_scope: \"bounded_prechain_skipped\"` — not a green), `ok: false` =\nbroken. Pass `strict: true` to make any unchained row a hard failure.\n\nThree are **agent tools** (0.7.0), for when the output is going to somebody —\na principal who wants proof, or a peer deciding whether to trust you:\n\n| tool | for | returns |\n| --- | --- | --- |\n| `prove_my_conduct(namespace, events)` | log a batch of what you just did and hand your principal one hash | `{rows, head_hash, chain_verified}` |\n| `verify_peer_ledger(jsonl_text, strict?)` | judge another agent's exported log from its text alone | `{ok, rows, first_break, declared_breaks}` |\n| `declare_break(namespace, reason)` | your log broke — name it instead of re-minting a chain | `{declared_line, declared_breaks, ...}` |\n\n`prove_my_conduct` re-verifies after appending, so an agent whose ledger has\nbeen tampered with gets `chain_verified: false` rather than a head hash with a\ngreen attached. `verify_peer_ledger` returns `first_break` as an **integer line\nnumber** (or null) so a calling agent can point at the exact bad row — it never\ntouches your ledger (the export is verified from a throwaway temp file, and the\ncall itself lands one row in `<log>.calls.jsonl` like every other tool call), and\nan export with no parseable rows returns `ok: null` (`verified_scope: \"empty\"`),\nbecause a green for sending nothing is the cheapest possible forgery. `declare_break` refuses when nothing is broken, and\nnever restores a green.\n\nAgent ledgers live one file per namespace under `--ns-dir` (default `ledgers/`\nbeside `--log`). A namespace is a name, not a path: `[A-Za-z0-9._-]`, traversal\nrefused rather than sanitized.\n\nMCP is JSON-RPC over stdio and this server speaks it directly — no SDK, no\nextra install.\n\n## Status\n\nCore library, CLI, and a drop-in **MCP server**, all tested: the library\nagainst edit / delete / reorder tampering (`test_ledger.py`), the MCP server\nthrough tools/list → append → verify at the request handler including\ntamper detection, and the agent tools against namespace traversal,\npeer-export tampering by exact line, and declared breaks (`test_agent_tools.py`). Extracted from a hash-chained action ledger\nrunning in production. External anchoring ships via `head()` (publish the pin\nyourself) and the reference witness (`WitnessStore`, above); a hosted witness\ntier (retention, automatic pin cadence, compliance export) is the next layer.\n\nMIT.\n",
  "bytes": 22834,
  "sha": "feec75ef8a406aedbb5948093017874315dd5dc02a05ef463dbd9aeb5f1183ec",
  "repo_slug": "dan8433-user/ledger",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_arcaeon_ledger_5632bd16/readme"
}