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 scanruns 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 anoutputandoutput_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 recordedpromptagainst the recordedprompt_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_chainpassing 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 aReading(data, provenance, error).provenancerecords the endpoint, params, and a SHA-256 of the exact JSON response. See "Failure handling" inRULES.mdfor how 404 vs 5xx is distinguished viaerror.eth_call/get_code/rpc_read- POSTs a JSON-RPC request to the public RPC, pinned toChainView.block_height(resolved once viaeth_blockNumberand cached for the rest of the scan -pin()only calls the RPC the first time it's needed). SameReadingshape,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):
- Opens a
ChainView, pins a block height (latest, unless--at-blockwas given). - Runs every rule in
RULESagainst it, in order. - Wraps the results in a
ScanArtifactand 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.