mizukara · internals

architecture

How the provenance tracking, hash chain, and rule engine actually work.

How the pieces fit together. For a high-level summary of what mizukara does and what's coming next, see Status.

src/mizukara/
  cli.py              typer app: onboard, gateway run, chat, scan, replay
  core/
    config.py          ~/.mizukara/ config: providers, approval policy, gateway token
    trail.py            TrailWriter / TrailReader / verify_chain - the hash-chained log
    replay.py            replay_trail() - chain verification + recorded-hash re-checks
    router.py           per-turn model tier selection
    memory.py            SQLite/FTS5 keyword store
    hashing.py           sha256_hex / sha256_json / canonical_json
    gateway.py           FastAPI app: /v1/chat /v1/tools /v1/memory /v1/trails /v1/scan
    tools/               file / shell / web / git, each behind an approval-policy check
  watchdog/
    chain_view.py        provenance-tracked Blockscout + JSON-RPC reads
    abi.py                minimal ABI encoding (selectors, address/uint encode/decode)
    artifact.py           the public scan artifact format (Fact, Provenance, ScanArtifact)
    engine.py             RULES list + run_scan() - the single entry point every
                            scan calls into
    rules/
      r1_source_verified.py
      r2_ownership.py
      r3_mint_authority.py
      r4_supply_concentration.py
      r5_liquidity.py

One engine backs both surfaces mizukara is built around, and everything here shares one design principle - every claim carries the exact query that produced it, hashed:

  • watchdog/ is the rule engine itself: point it at an address, it reads chain 4663 and Uniswap directly, and returns a scored artifact. No daemon required - mizukara scan runs it inline. The same engine will power the public watchdog instance (auto-scanning every new deploy and posting findings) and the personal wallet guard (watching a registered wallet and stepping in on a bad buy) - both on the roadmap.
  • core/ is the always-on daemon that layer runs on: a gateway process with a tamper-evident decision log, running on your own machine. Today it backs the scanner's logging and the chat client, and it's the same piece of infrastructure a wallet guard needs - a trusted local process that logs every decision it makes before it's allowed to act on your funds.

They already meet at one seam: the gateway's /v1/scan endpoint calls watchdog.engine.run_scan(), the same function the CLI calls directly. That's what makes "same code, same logs" true today, not just as a claim - see RULES.md for what those rules actually check.


The decision trail

File: core/trail.py

An append-only JSONL file, one per gateway session, at ~/.mizukara/trails/*.jsonl. Line 1 is a header; every line after that is one event.

Header:

{
  "trail_header": true,
  "mizukara_version": "0.1.0",
  "config_snapshot": { "...": "redacted config, no secrets" },
  "versions": { "mizukara": "...", "python": "...", "platform": "..." },
  "header_hash": "sha256 of the above three fields"
}

Event:

{
  "index": 0,
  "ts": "2026-...",
  "type": "input",
  "data": { "...": "event-specific payload" },
  "prev_hash": "the previous event's event_hash, or header_hash for index 0",
  "event_hash": "sha256({index, ts, type, data, prev_hash})"
}

Valid types: input, route_decision, provider_call, tool_call, memory_recall, output - TrailWriter.append() raises ValueError on anything else.

Each event's hash commits to its own content and the previous event's hash - a standard hash chain. TrailWriter is append-only and resumable: reopening the same path picks up _index and _prev_hash from the last line on disk rather than starting over, so a restarted gateway process continues the same chain instead of forking it.

verify_chain(path) (in trail.py) recomputes every event's hash from its recorded fields and checks it against the recorded event_hash, and checks every prev_hash links to the actual preceding event. TrailEvent(**json.loads(line)) reads the record back into the same dataclass that produced it - if a byte anywhere in an event changed, the recomputed hash won't match, and verify_chain reports exactly which index broke. This was demonstrated live: editing one word in a past input event's text and re-running mizukara replay flips chain_ok from true to false immediately, citing the exact index and reason (event_hash does not match recomputed hash).

Replay

File: core/replay.py, exposed as mizukara replay <trail>

Replay is not re-execution - it proves what happened, not that an LLM would say the same thing twice. Concretely, per event type:

  • tool_call: if the event recorded an output and output_sha256, recompute the hash of the recorded output and compare. verified / hash_mismatch / skipped (no recorded output to check).
  • provider_call: recompute the hash of the recorded prompt against the recorded prompt_sha256. The provider is never re-called - LLMs aren't deterministic, so replay only proves the prompt hasn't been altered since it was logged, and serves the recorded response back verbatim (replayed_from_record).
  • Everything is gated on verify_chain passing first. If the chain itself is broken, no per-event replay runs at all (ReplayReport.results == []) - a broken chain means nothing downstream of the break can be trusted anyway.

ReplayReport.ok is true only if the chain verified and every event result is verified, replayed_from_record, or skipped - a single hash_mismatch anywhere fails the whole report and the CLI exits 1.

Provenance-tracked chain reads

File: watchdog/chain_view.py

Every rule reads chain state through one ChainView instance, never through raw httpx calls of its own. Two read paths:

  • blockscout_get(endpoint, params) - GETs a Blockscout v2 endpoint, returns a Reading(data, provenance, error). provenance records the endpoint, params, and a SHA-256 of the exact JSON response. See "Failure handling" in RULES.md for how 404 vs 5xx is distinguished via error.
  • eth_call / get_code / rpc_read - POSTs a JSON-RPC request to the public RPC, pinned to ChainView.block_height (resolved once via eth_blockNumber and cached for the rest of the scan - pin() only calls the RPC the first time it's needed). Same Reading shape, provenance.type == "rpc".

Rules build calldata themselves using watchdog/abi.py - a deliberately minimal ABI encoder (selector = real Keccak-256 of the function signature via pycryptodome, plus address/uint encode and decode), not a general-purpose ABI codec. Selectors are computed, not hardcoded, so owner()0x8da5cb5b and balanceOf(address)0x70a08231 are verifiable from the signature string alone, and were cross-checked against those well-known values during development.

Every Reading a rule touches becomes a Fact.source in the artifact - there is no path from "rule made a network call" to "artifact fact" that doesn't pass through this provenance record.

The rule engine and scan artifact

File: watchdog/engine.py, format in watchdog/artifact.py

A rule is a module with id, description, and run(chain_view, address) -> RuleResult. engine.RULES is the literal, ordered list of what's registered - see RULES.md for what each one does. run_scan(address, block_height=None):

  1. Opens a ChainView, pins a block height (latest, unless --at-block was given).
  2. Runs every rule in RULES against it, in order.
  3. Wraps the results in a ScanArtifact and closes the chain view.

The artifact (ScanArtifact.to_json()):

{
  "mizukara_version": "0.1.0",
  "target": "0x...",
  "chain": 4663,
  "block": 11688500,
  "rules": [
    { "id": "R1", "verdict": "pass", "facts": [
      { "desc": "...", "source": { "type": "blockscout", "endpoint": "...", "params": {...}, "response_sha256": "..." } }
    ]}
  ],
  "narrative": "",
  "narrator": null,
  "trail": null,
  "artifact_sha256": "sha256 of everything above"
}

artifact_sha256 is computed last, over the fully-assembled payload (including the null narrator/trail fields when unset) - it's the thing a public findings page or the X bot would link to as the fingerprint of this scan. narrative and narrator are reserved for the upcoming narrator layer; trail is reserved for linking a public-instance scan to its own decision trail. Pinning to a specific block (--at-block) is what makes artifact_sha256 reproducible - the same address at the same block height re-runs every rule against identical on-chain state and produces byte-identical facts and hash.

Gateway

File: core/gateway.py, started via mizukara gateway run

A FastAPI app bound to 127.0.0.1 by default - binding anywhere else requires --expose, checked in the CLI before the server even starts. Bearer-token auth (generated at onboard, stored in ~/.mizukara/config.json) gates every endpoint except the read-only trail lookup.

Endpoint Method Does
/v1/chat POST Writes input + route_decision events to the trail and returns the routing decision. Full model replies are coming next - see Status.
/v1/tools GET Lists available tools and the current approval policy.
/v1/memory GET Runs a keyword recall query, logs a memory_recall event.
/v1/trails/{name} GET Path lookup for a trail file, scoped to the trails directory (no auth - it's a read-only path check, not the trail content itself).
/v1/scan POST Calls watchdog.engine.run_scan() directly - the same function mizukara scan calls.

Config and data directory

File: core/config.py

Everything lives under ~/.mizukara/: config.json (provider list, approval policy, gateway token and port - 0600 permissions), trails/ (one JSONL per gateway session), mizukara.db (the SQLite/FTS5 memory store). Config.to_snapshot() is what gets embedded in a trail header - it redacts the gateway token and reduces the provider map to just its key names, so a trail file never leaks a secret even though it records the config that produced the session.

Roadmap

Everything in this document runs today, verified against real data on chain 4663. For what's coming next - the narrator layer, automated monitoring, public findings, rules R6–R9 and more - see Status.