What happens to one row.
A row starts as an ordinary Sibyl write, gets anchored on Base inside an encrypted epoch, comes back on another machine through Sibyl's own write methods, and is checked before the agent acts on it.
Four paths#
This page follows one row through those four paths (write, anchor, restore, read and decide), then names the pieces they share and every file kint keeps on disk.
agent (Claude Code, Codex, Hermes, OpenClaw)
│ MCP over stdio
▼
kint-server
├── Sibyl's eight tools, unmodified ──┐
└── kint's six tools ─────────────────┤ one MemoryClient, one cap gate
▼
Sibyl's store: $SIBYL_MEMORY_DB or ~/.sibyl-memory/memory.db
│
anchor: diff against the mirror, after the write returned
▼
EpochAnchor on Base: one transaction per epoch, ciphertext in calldata
│
restore: walk, check, decrypt, replay through Sibyl's write methods
▼
the same store on the next machine
│
read and decide: Sibyl's search, then each hit's leaf against the mirror
▼
proceed, or refuse and write the refusal backWrite: Sibyl's tools, unchanged#
kint-server calls build_server() from sibyl_memory_mcp.server and gets Sibyl's eight tools exactly as shipped: memory_remember, memory_recall, memory_search, memory_list, memory_forget, memory_set_state, memory_get_state and memory_record_event. Then it adds six of its own: memory_status, memory_connect, memory_pull, memory_push, memory_verify and memory_history.
When the agent calls memory_remember, Sibyl's tool calls set_entity on its client, and the row lands in Sibyl's SQLite file in Sibyl's format. kint changes nothing in that write. On the one client it builds, kint wraps set_entity, archive_entity, delete_entity, set_state and set_reference so that, once a write has returned, it re-reads that row and notes its leaf: that is how kint-server tells a write through the tools from an edit behind them (When a push is held). The store is $SIBYL_MEMORY_DB, or ~/.sibyl-memory/memory.db when that is unset. The tenant is KINT_TENANT, falling back to the tenant_id and then the account_id in Sibyl's credentials, and last to Sibyl's default tenant.
The only kint code that runs during a Sibyl write is the size function inside the cap gate: Sibyl's own measurement plus a sum of the file sizes under KINT_HOME. Nothing touches the chain, an RPC or a signer inside a Sibyl write transaction.
Anchor: push, after the write returned#
Push reads the store after Sibyl has committed, and it never runs once per write. Inside kint-server every push (the watcher's, the exit hook's and memory_push) first passes the drift hold; kint push and kint compact in a terminal do not. One push goes like this:
- It takes the head lock for the space.
- It refuses when this machine is not connected; when the machine created the vault but its recovery code file is gone; when the data key is missing or expired ("nothing was dropped, the changes stay in the store until the next push"); when no key wraps are recorded for the space; or when the last pull left a gap. The one way past a gap is the owner's
--over-skippedsnapshot, from a terminal (Epochs on Base). - It exports every row this tenant holds in the four tiers the SDK writes (entities, state, reference, journal), reading the store with read-only SQL in rowid order.
- It diffs those rows against the mirror. A row counts as changed when its leaf (a hash of its exact stored text) differs from the leaf the mirror holds, and as deleted when the mirror has it and the store does not. An empty diff stops here with
nothing to push: the store matches the last anchored epoch, and no RPC is called. A snapshot (kint compact,memory_push(snapshot=true),kint rekey) skips that stop and carries every row, even when nothing changed. - It reads the chain head. If that is not the head the mirror recorded, another machine has pushed, so it refuses (
CHAIN_MOVED): runkint pullfirst, orkint pull --discard-localwhen unanchored local changes make that pull a fork. Under--over-skippedthe snapshot chains on the chain head instead. - It checks on chain that this machine's session key may write for the owner.
- It splits the change set so that each epoch's measured gzip size stays under 90 KB. A single row too large to fit one epoch on its own is named in a refusal before anything is sent, and push refuses until that row is shrunk or split in Sibyl. A snapshot is never split.
- For each epoch it computes the merkle root of the full state after that epoch, then compresses, pads to a size bucket, and encrypts under the data key. It sends one transaction from the session key and waits for its confirmations (2 by default). It checks that the
Epochevent's digest is the keccak of what was sent. Finally it caches the epoch, saves the mirror and raises the watermark.
The epoch format, and what Base can see of an epoch, are covered in Epochs on Base.
Note. Push anchors whatever the store holds when the diff runs, and a push from your terminal does exactly that. kint-server's own pushes are held instead when a row the chain vouches for changed behind Sibyl's tools while the server ran, or when verify already refused the value a row now holds (When a push is held). An edit made while no server was running, and never refused, still goes out with the next session's first push. Checking a row against what the chain vouched for is the job of read and decide, which is why the agent runs that step before acting.
When push runs#
| Trigger | What starts it |
|---|---|
| Quiet | The store changed and then stayed unchanged for KINT_QUIET_SECONDS |
| Size | The estimated unanchored change (400 bytes per changed or deleted row) passed KINT_SIZE_TRIGGER_BYTES. This is checked at most once a minute while the store is dirty, however busy the agent is |
| On demand | memory_push or kint push, or a snapshot: memory_push(snapshot=true) or kint compact |
| On exit | kint-server exits normally or receives SIGTERM or SIGHUP. The signal handler only unwinds, so nothing touches the chain inside a Sibyl write; then an exit hook makes one best-effort push, when this machine is connected, the data key is cached and the push is not held |
The quiet and size triggers come from the watcher, a background thread that kint-server starts after its startup pull. Every KINT_POLL_SECONDS the watcher reads the modification time of the store and of its -wal file, and any change marks the store dirty. The watcher pushes nothing while this machine is not connected or the data key is not cached. Some writes change nothing that needs anchoring (a pull's replay also moves the modification time), so when the once-a-minute diff finds nothing unanchored, the watcher clears the dirty mark. At the defaults, the size trigger fires at 82 changed or deleted rows.
| Variable | Default | Meaning |
|---|---|---|
KINT_QUIET_SECONDS | 300 | Seconds without a store change before the watcher pushes |
KINT_POLL_SECONDS | 15 | How often the watcher looks at the store. Values below 1 become 1 |
KINT_SIZE_TRIGGER_BYTES | 32768 | The estimated unanchored size at which the watcher pushes without waiting for quiet |
KINT_NO_WATCHER | unset | 1 starts kint-server without the watcher. The exit push still runs |
If a value is not a number, kint logs a warning and uses the default. The other variables are listed in Configuration.
Two pushes never overlap. Inside one server, a second request gets BUSY ("a push is already running"). Between processes on one machine, the head lock makes them take turns.
The exit push is best effort. A server killed before its exit push lands anchors nothing, and Claude Code gives a closing server about half a second before it kills it, less than a push with its confirmations takes, so under Claude Code the exit push rarely completes. The rows it missed stay in the store, unanchored, and the next kint-server session over that store picks them up: its watcher starts dirty, logs watcher: N changed and M deleted row(s) were left unanchored by an earlier session; they are pushed at the next quiet period, and pushes them once the quiet period passes. kint push anchors them at once.
When a push is held#
kint-server follows every row its own tool path writes. Sibyl's eight tools and kint's six share one client, and the wrapped write methods move a row's baseline to the leaf that was just written. The baseline becomes the whole store again when the server starts and after every successful pull and push.
Before a server-side push (the watcher, the exit hook, memory_push), kint looks at every row whose stored leaf differs from the leaf the mirror says was anchored, and holds the push when either is true:
- a
kint_refusalentity in the store names exactly the value the row holds now, somemory_verifyalready refused it; - the row changed, or an anchored row vanished, while this server ran, and no write through its tool path explains it (an edit straight into SQLite, say).
A row that was never anchored is not drift. A store kint cannot read holds the push too, because an unchecked push is what the hold exists to stop. A held push anchors nothing and drops nothing:
{"ok": false, "error": "HELD", "message": "refusing to anchor: 1 row(s) changed behind Sibyl's tools while this server was running (entity rules/release-gate). Anchoring would make the chain vouch for a value kint cannot account for. Check the store, then `kint push` from your own terminal (or `kint pull --discard-local` to restore the anchored value).", "hint": "nothing was anchored and nothing was dropped; memory_verify still refuses the row"}The log gets push HELD (<reason>): <message> once per distinct message, and the watcher waits for the next store change instead of retrying. memory_status works the hold out again on every call and returns it as held: {"message", "reason", "at"}, where reason names what tried to push (watcher, exit, tool, or status when the status call found it first), or null when nothing is held.
The way out is yours to take. kint push from a terminal is never held: check the store, then anchor it as it is. kint pull --discard-local moves the store aside and restores the anchored values; the unanchored rows, a refusal entity included, stay in the .kint-backup- copy.
The hold only knows this server's own tool path. A write into the same store from another process (a second session's kint-server, an SDK-direct agent) looks like an edit behind Sibyl's back, so this server holds until that process's push anchors the row, or until you run kint push. An edit made while no server was running is not seen at all: the next session's baseline starts from the edited store, and its first push anchors the edit unless verify refused it first.
Restore: pull, through Sibyl's write methods#
A pull runs in three places. kint-server starts one when it starts, if this machine is connected and the data key is cached, and waits for it up to KINT_BOOTSTRAP_SECONDS (default 20) before it serves. A pull still running by then finishes in the background (bootstrap: still running; serving now, the pull finishes in the background), and every RPC call gives up after KINT_RPC_TIMEOUT seconds (default 10), so a hung endpoint cannot keep the tools from being listed. The agent can call memory_pull, and a human can run kint pull. If the startup pull hits a fork, a freshness refusal or any other failure, kint logs it and the server serves the local store as it is.
Pull takes the head lock and refuses when this machine holds no data key. Then the checks run in this order, each one before the next:
- Freshness. The watermark is a floor. A pull refuses a head older than the one this machine already saw, or one with the same seq and a different digest, as a stale or lying RPC. When there is no watermark (a cold start), a second RPC that is a different endpoint must agree on the head's seq and digest, or the pull refuses.
KINT_ALLOW_SINGLE_RPC=1accepts a single RPC instead. - Fork. If the store already holds rows while this machine has no mirror, pull never merges them blindly. If the store has unanchored changes while the chain moved, that is a fork, and pull refuses rather than lose them.
kint pull --discard-local(memory_pull(discard_local=true)) moves the store file and its-waland-shmfiles aside to timestamped.kint-backup-copies and restores from the chain. - Walk. The walk starts at the head and goes back through the
prevBlockfield of eachEpochevent, one exact-block log query per epoch, down to the epoch this machine already holds. Without--fullit also stops at the newest snapshot epoch, whose rows are the whole state.--full(full=true) walks past snapshots, and afterwards caches, for history, every epoch from the head to the first that this machine has not decrypted yet. - Each epoch. Pull checks prev continuity, then takes the ciphertext from the epoch cache or the transaction's calldata (a
pushcall whose owner, space and prev match the event) and trusts it only when its keccak equals the event digest. It checks the header and the data key, then the AES-GCM tag against an AAD that binds chain id, owner, space, seq, prev, bucket, rows root and data key id. The plaintext's own seq, space, prev and rows root must agree with the chain and the header, and its snapshot key must agree with the header's snapshot flag. - Replay. Rows go back through
MemoryClient.set_entity,set_state,set_referenceandwrite_event, the SDK's own write methods, so each one passes Sibyl's cap gate and is searchable again. Deleted entities go throughdelete_entity. Pull re-reads every replayed row and compares its leaf, and compares the running merkle root with the rows root in the epoch header. A row that does not come back byte-exact, or a root that does not match, is reported as a warning in the pull report. - Record. One epoch at a time, pull caches the epoch, saves the mirror and raises the watermark.
An epoch that cannot be applied is a gap. When this machine cannot open it and a later snapshot in the walk opens, the pull resumes at that snapshot and ends complete. Otherwise the pull stops there and the mirror stays at the last epoch it applied. Push and verify refuse until the gap is applied, and the next pull retries from that point (Gaps). A snapshot epoch replaces the local picture. Rows the store already holds byte for byte are not written again, because a journal event has no key to overwrite. Replay is content-exact: row uuids and the entity, state and reference timestamps regenerate, but none of those fields are in the leaf, and the journal timestamp survives.
The pull ends by hashing the store itself and reporting whether the store root matches the anchored root. Restoring sends no transaction. The steps for a wiped machine are in Restore on a new machine.
Read and decide: verify#
memory_verify(query, limit=5), or kint verify "query" from a shell, is the step between recall and action:
- It calls
multi_record_searchon the shared client, the search Sibyl's ownmemory_searchruns by default, with every precision gate, and gets ranked hits with a typed verdict. A verdict other thanok(no_match,empty_store,abstained_on,negation_abstain,gated), or no hit at all, means a refusal, because there is nothing to key a decision on. - It refuses when this machine has never pulled or pushed, or when its mirror is incomplete (a gap, or a root that differs from the anchored one).
- It refuses when the chain head, read just before verify ran, is not the head the mirror recorded: another machine anchored something this one has not pulled (
chain moved to seq N at block B, pull first: ...). - For every hit it re-reads the exact stored text by the key the search returned and hashes it into a leaf. It compares that with the leaf in the mirror and checks a merkle inclusion proof against the anchored root. Each hit comes out as
verified,drifted,unanchoredormissing. - If any hit is
driftedormissing, verify refuses. For a drifted row the reason names the block that anchored the last good value and the head block this machine last saw. It writes the refusal back throughset_entityas a Sibyl entity in categorykint_refusalwith statusrefused(kint verify --no-writeskips that write). The next fresh session finds it with Sibyl's own tools, and while the row keeps the refused value, the refusal holds kint-server's automatic pushes. - If every hit is unanchored, it refuses. Otherwise it proceeds on the top verified hit and names any higher-ranked hit that is not anchored yet.
Verify sends no transaction. It reads the chain head once, outside every Sibyl write. When that read fails (or under KINT_OFFLINE=1) it says so and checks against what this machine last saw anchored, which push and pull checked against the chain when they ran. memory_status reports whether the mirror is still the chain head (in_step). The rules, the reasons and the temporal read are in Verify before acting.
The pieces#
One client#
Sibyl's MCP server keeps a single MemoryClient in a module-level _client_cache and rebuilds it only when credentials.json changes, appears or disappears. kint builds its client first (store.open_client) and writes it into that cache under Sibyl's own _client_lock, along with the current credentials mtime. Sibyl's tools then find a cached client that looks fresh and use it. No line of Sibyl is patched. kint's tools use the same client, and a pull that has epochs to apply builds a new one, after any wipe, and seeds it again.
The client reads the same credentials file as Sibyl's own and keeps the same tier_cache.json beside the store. The change kint makes on purpose is the cap gate's size function. If credentials.json changes while the server runs (Sibyl's own trigger, for sibyl upgrade), Sibyl rebuilds its cached client its own way. Until the next pull or restart, their tools then run on Sibyl's client instead of kint's. That client carries none of kint's wrapped write methods, so their writes look like edits behind the tools, and kint-server's automatic pushes are held until the next pull, a restart or a kint push.
The cap gate#
Sibyl's free tier is capped at 5 MiB (5,242,880 bytes) per account. Sibyl measures usage with aggregate_db_size, which sums the stores at five known paths (listed at the end of this page). kint builds the client with Sibyl's public CapGate and sets db_size_fn to that same aggregate plus footprint_bytes(), the size of every file under KINT_HOME. The gate enforces the cap, it does not just report it. A write that would take the sum past the cap meets Sibyl's gate exactly as Sibyl's own overflow would, and that includes rows a pull replays.
kint status prints the three numbers: Sibyl's stores, kint's state, and the enforced total against the cap. memory_status returns them as cap, with the fields sibyl_bytes, kint_bytes, volunteered_total, cap_bytes and db_path.
Sibyl can consult the gate while it holds its BEGIN IMMEDIATE write lock. That is why kint's part of the gate is a local file walk and nothing more.
The head lock#
head_lock takes an exclusive flock on ~/.kint/head-<space>.lock. It retries every 0.2 seconds for up to 30 seconds, then fails with another kint process holds the head lock for this space. Push, pull and kint rekey hold it, so two harness sessions over one store take turns instead of racing. The lock only covers one machine. Between machines the chain head decides: a push that finds the head moved refuses, and a pull over unanchored changes refuses as a fork.
The mirror and the watermark#
The mirror (mirror-<space>.json) is what this machine believes the chain last saw. It holds every row in wire form (text included), each row's leaf, and the head those rows came from: seq, digest, block, bucket, transaction, the anchored rows root, and any epochs a pull had to skip. Push diffs against the mirror, verify compares leaves against it, and pull advances it one applied epoch at a time. The mirror is complete when no epoch was skipped and the root of its leaves equals the anchored root. Verify refuses while it is not complete, and also when the chain head has moved past it; push refuses while an epoch is skipped, except for the owner's --over-skipped snapshot.
The watermark (watermark-<space>.json) holds the seq, digest and block of the newest epoch this machine has pushed or applied, and its seq never decreases. A pull checks it first as the freshness floor. A pull is a cold start precisely when there is no watermark.
The epoch cache#
epochs/<space>/ keeps three files per epoch, named by the eight-digit seq:
- the ciphertext (
.bin) - the decrypted plaintext (
.json) - the metadata (
.meta.json): seq, digest, prev, block, transaction, bucket, rows root and writer, plus the cost for an epoch this machine pushed
Pull uses a cached ciphertext only when its keccak still equals the event digest, and otherwise fetches the calldata again. memory_history and verify's provenance read the plaintext files, so a machine's history covers only the epochs it has decrypted. After a cold start that stopped at a snapshot, the history begins at that snapshot, a later pull that stops at a newer snapshot skips the diff epochs before it, and kint pull --full backfills both.
Where kint keeps its state#
Everything lives under KINT_HOME, which defaults to ~/.kint:
~/.kint/ mode 0700
├── kint.log the server's log (stdout belongs to the MCP transport)
├── session.key this machine's session key, a V3 scrypt keystore
├── session.key.old the previous one, after kint session-key rotate
├── local.secret only when neither the macOS Keychain nor KINT_SESSION_PASSPHRASE supplies the machine secret
├── enrol-<space>.json owner, tenant, and the key tags this machine can open
├── wraps-<space>.json the data key's wraps
├── vault-<space>.aes the cached data key, encrypted, with an expiry
├── RECOVERY-<space>.txt the recovery code: written for a fresh vault, by kint recovery-code, and by kint rekey
├── mirror-<space>.json what this machine last saw anchored
├── watermark-<space>.json the freshness floor
├── head-<space>.lock the head lock
└── epochs/
└── <space>/
├── 00000001.bin ciphertext
├── 00000001.json decrypted plaintext
└── 00000001.meta.json seq, digest, prev, block, tx, bucket, rows_root, writer<space> is the first 16 hex characters of the space id, keccak256("kint-space-v1" || tenant). kint writes its state files with mode 0600 and replaces them atomically (the log and the lock file are opened in place). The keys, the wraps and the recovery code are explained in Keys and custody.
Why ~/.kint sits outside Sibyl's paths#
Sibyl's aggregate_db_size sizes the stores at these five paths, WAL included, and counts each store once by resolved path:
- the store the client is writing to
~/.sibyl-memory/memory.db$HERMES_HOME/sibyl/memory.db(HERMES_HOMEdefaults to~/.hermes)$HERMES_HOME/sibyl/profiles/<profile>/memory.db, for every profile$SIBYL_MEMORY_DB, when it is set
kint's state sits outside all of them so that the two halves of the count never overlap. Sibyl's number is exactly what Sibyl measures without kint, kint's number covers every file under KINT_HOME, and the gate enforces the sum. A KINT_HOME that contained one of those stores would count it twice. kint doctor fails its kint home placement check when KINT_HOME is inside ~/.sibyl-memory. Kept apart, the bytes kint adds to a machine stay visible, counted, and held to the cap Sibyl already enforces.
Read Keys and custody next.
Source: src/kint/server.py, src/kint/push.py, src/kint/pull.py, src/kint/store.py, src/kint/paths.py, src/kint/epoch.py, src/kint/restore.py, src/kint/verify.py.