Recalled is not the same as vouched for.
memory_verify re-reads the exact stored text of a recalled row, hashes it and checks it against what Base anchored. The answer is proceed or refuse, and a changed row is refused with the blocks that prove it.
The beat#
Something the agent remembers is not proof that it still holds what was anchored. So before an agent acts on a recalled row, verify asks Sibyl for it and checks what Sibyl found against the chain. memory_history then shows what the row held at every anchored epoch.
The demo tenant kint-demo, head at epoch 2, block 51081880. The output below keeps the lines kint verify prints; ... marks lines cut here (one line prints per checked hit), and the placeholders in angle brackets stand for values not recorded in the transcript.
$ kint verify "release rule"
query: release rule
sibyl verdict: ok hits: <n>
verified entity rules/release-gate: stored text matches the leaf anchored on Base
...
DECISION: PROCEED: entity rules/release-gate verified against rows_root 390336ff71dbcb85 anchored at epoch 2, block 51081880
$ sqlite3 ~/.sibyl-memory/memory.db "update entities set body = replace(body, 'never', 'always') where name = 'release-gate'"
$ kint verify "release rule"
query: release rule
sibyl verdict: ok hits: <n>
drifted entity rules/release-gate: the stored text of this row no longer matches what was anchored: the chain vouches for 4ac605528bbb00e7 (epoch 2, no later than block 51081880), the store now holds <new leaf> (unanchored, head epoch 2 at block 51081880)
...
DECISION: REFUSE: entity rules/release-gate: the stored text of this row no longer matches what was anchored: the chain vouches for 4ac605528bbb00e7 (epoch 2, no later than block 51081880), the store now holds <new leaf> (unanchored, head epoch 2 at block 51081880)
refusal written back as Sibyl entity kint_refusal/entity-rules-release-gate-<unix seconds>
$ kint history entity release-gate --category rules
epoch 1 block <= 51081867 leaf f406bb0919d23360: {"rule":"never ship on a Friday; every release needs a green deadlift test and a second reviewer", ...}
epoch 2 block <= 51081880 leaf 4ac605528bbb00e7: {"rule":"never ship on a Friday; every release needs a green deadlift test, a second reviewer, AND a passing kint verify", ...}The edit went straight into Sibyl's SQLite file, behind Sibyl's back. Sibyl still finds the row and still ranks it. kint catches it because the stored text no longer hashes to the leaf epoch 2 anchored. history still shows both anchored versions, because it reads the decrypted epochs, not memory.db.
If kint-server is running when an edit like that lands, its automatic push does not anchor it. The row changed behind Sibyl's tools while the server ran, so the watcher's push comes back HELD, anchoring nothing and dropping nothing, and memory_status shows why under held. Once verify has refused the value, the refusal entity holds it too, in this session and the next. When a push is held has the rules.
Sibyl finds the row#
Verify starts with the search Sibyl's own memory_search runs by default: multi_record_search(client, query, limit=limit) from sibyl_memory_client.multi_record. Inside kint-server the client is the same MemoryClient that serves Sibyl's eight tools; kint verify builds its client the same way. It is Sibyl's two-stage retrieve-then-verify search. Candidates come from the client's own full-text search over four FTS5 indexes (entities_fts, state_documents_fts, reference_documents_fts, journal_events_fts), then Sibyl's precision gates drop any candidate the query does not support, or abstain on the whole query. So verify applies the same gates the agent's own search does.
Every Sibyl search returns a SearchResults: a list of hits that carries a typed verdict from sibyl_memory_client/verdicts.py. The VerdictCode set is closed:
| code | meaning (from Sibyl's explain) |
|---|---|
ok | rows matched; the only code on a non-empty result |
no_match | the query ran with every gate armed and matched nothing, or carried no searchable terms |
empty_store | the store holds no searchable rows for this tenant |
abstained_on | one content word in the query appears nowhere in the store and blocks the whole query |
negation_abstain | the query is negated and full-text search cannot honour a negation |
gated | candidates were found, then dropped by a relevance gate |
Through this search every code is reachable; Sibyl's NEGATION_POLICY is abstain, so a negated query comes back negation_abstain. kint keys the decision on the code: anything other than ok, or zero hits, means there is no ranked candidate to act on, and verify refuses. The plain client.search primitive has no abstention gate and would have answered some of these queries with rows. kint's test asks for Friday spaceship on a store that has a Friday rule and no spaceship: client.search answers ok, and verify refuses on abstained_on. kint returns its own copy of the verdict with code, returned, recovery, tokens, gate (only when set) and explain:
{"code": "ok", "returned": 3, "recovery": "none", "tokens": [], "explain": "3 row(s) matched."}Take Sibyl out and nothing gets decided: the epoch is a decrypted blob with no query surface, and there is no verdict to key on.
Re-read the exact stored text#
A search hit carries a snippet, a highlighted excerpt. kint never hashes the snippet. For every hit it re-reads the row by the key the search returned (tier, key, category) through a read-only SQLite connection (mode=ro), the same columns the exporter reads:
| tier | lookup | columns hashed |
|---|---|---|
| entity | category and name | category, name, status, body |
| state | document_key | document_key, body |
| reference | doc_key | doc_key, body, metadata |
| journal | the event id the search returned | ts, evaluated, acted, forward, extra |
The text is hashed exactly as stored. Sibyl's storage.dumps() keeps insertion order on purpose, so a row parsed and re-serialised with sorted keys would hash differently and every row would refuse on a second machine.
A journal event gets a new uuid when it is replayed on another machine, so its identity is its content: the key is keccak256 over kint-journal-key-v1 and the five content columns. A second identical event gets :1, the third :2, in rowid order.
The leaf#
The leaf is keccak256 over the prefix kint-leaf-v1 followed by the row's fields, each written as a 4-byte big-endian length and its UTF-8 bytes (a null field is the length 0xFFFFFFFF and no bytes):
entity, state, reference: kint-leaf-v1 | tier | category | key | status | body | meta
journal: kint-leaf-v1 | "journal" | null | key | null | ts | evaluated | acted | forward | extraupdated_at is left out, because Sibyl regenerates it when a row is replayed. The journal ts is content (write_event takes it explicitly), so it is in.
The proof#
The mirror is this machine's record of what the chain last saw: every row, its leaf, and the head (seq, digest, block). It lives at ~/.kint/mirror-<first 16 hex of the space>.json (under KINT_HOME). Pull and push fill it; connecting a machine that has none writes an empty one at seq 0. Its anchored_root is the rows_root anchored with the head epoch.
The root orders leaves by canonical id (tier, category, key joined by a null byte), never by rowid, so every machine computes the same root over the same content. Pairs hash as keccak256 of the two children; an odd node is carried up unchanged; an empty set has the root keccak256 of kint-empty-v1.
When the stored leaf equals the mirror's leaf, verify builds a merkle inclusion proof for the row, a list of [sibling, sibling_is_left] pairs, and checks that it reproduces the mirror's root. Past seq 0 the decision has already required that root to equal the anchored rows_root (step 3 below). The proof and its result travel back in the check as proof and proof_ok.
Note. Verify sends no transaction and makes one read: the chain head for this owner and space, taken before verify runs and outside every Sibyl write. When that head is not the one this machine last pulled or pushed, verify refuses before it checks a row. When the read fails, or
KINT_OFFLINE=1, verify checks against the mirror and the epoch cache alone and says so:kint verifyon stderr,memory_verifyin the server's log.
Four statuses per hit#
| status | when | reason |
|---|---|---|
verified | the stored leaf equals the anchored leaf and the proof reproduces the root | stored text matches the leaf anchored on Base |
drifted | the stored leaf differs from the anchored leaf | the stored text of this row no longer matches what was anchored: the chain vouches for <leaf> (epoch <seq>, no later than block <block>), the store now holds <leaf> (unanchored, head epoch <seq> at block <block>) |
drifted | the leaves match but the proof fails | leaf matches but the inclusion proof against the anchored rows_root failed |
unanchored | the row is in the store but not in the mirror | this row was written after the last anchored epoch; nothing on the chain vouches for it yet (push first) |
missing | the key the search returned no longer reads back | the row the search returned is no longer in the store |
Two blocks appear in a drift. The anchoring block comes from the epoch cache: the last epoch that changed this row. A snapshot epoch re-anchors every row, but it does not move the provenance of a row it did not change, so a kint compact leaves the named block where the value was written. (On a machine that restored from a snapshot and never ran kint pull --full, the snapshot is the oldest epoch it holds, and its block is the one named.) The head block is the mirror's head: the newest epoch this machine applied.
The decision#
Verify walks these checks in order and stops at the first that applies:
- Sibyl's verdict is not
ok, or there are no hits: refuse withno ranked candidate to act on: Sibyl's verdict is <code>. - There is no mirror file at all (normally a machine never connected under this
KINT_HOME): refuse withthis machine has never pulled or pushed: nothing on the chain has been checked. A machine that connected but never pulled has an empty mirror at seq 0 instead; step 4 refuses when the chain has moved past it, and otherwise every hit comes backunanchoredand step 6 refuses. - The mirror is past seq 0 but incomplete: refuse with
this machine's picture of the memory is not the one the chain vouches for: <why>; pull again. The why isepochs <list> could not be applied on the last pullwhen a pull stopped at a gap, orthe local mirror's root does not equal the rows_root anchored on the chainwhen the replayed rows did not reproduce the anchored root. - The chain head was read and its seq or digest is not the mirror's: refuse with
chain moved to seq <N> at block <B>, pull first: this machine last saw seq <M> (<12 hex>), so the row it would check may be superseded. Another machine anchored something this one has not pulled, and nothing is checked against a stale picture. - Any hit is
driftedormissing: refuse on the first one in rank order, with<tier> <category>/<key>: <reason>(state, reference and journal rows have no<category>/), and write the refusal back. - No hit is
verifiedand at least one isunanchored: refuse withevery candidate is unanchored: nothing on the chain vouches for it yet; push, then verify. - Otherwise proceed, on the highest-ranked verified hit:
<tier> <category>/<key> verified against rows_root <16 hex> anchored at epoch <seq>, block <block>, where the epoch and block are the mirror's head.
An unanchored hit is never credited as verified, even when Sibyl ranks it first. If unanchored hits outrank the verified one, the reason says so: ; NOTE <n> higher-ranked hit(s) are UNANCHORED and not vouched for by the chain (<names>): act on the verified row, push before relying on the others. The test for this writes rules/release-gate-v2 saying "ship on a Friday whenever" on top of an anchored rules/release-gate; the decision is proceed on release-gate, and when release-gate-v2 ranks first the reason names it as unanchored.
The refusal is written back#
A refusal over a drifted or missing row is written into the store as a Sibyl entity, through Sibyl's own set_entity, so the next fresh session finds it with Sibyl's tools like any other entity:
| field | value |
|---|---|
| category | kint_refusal |
| name | <tier>-<category or none>-<key>-<unix seconds>, cut to 200 characters |
| status | refused |
| body | refused (true), query, tier, category, key, reason, local_leaf, anchored_leaf, anchored_seq, anchored_block, anchored_tx, head_seq, head_block, at (UTC) |
The result names it as kint_refusal/<name>. It is an ordinary entity, and itself an unanchored change. While the row still holds exactly the refused value, the refusal also holds kint-server's automatic pushes (When a push is held), so it reaches Base with whatever settles the row: kint push from your terminal anchors both, and kint pull --discard-local restores the anchored value and leaves the refusal in the backup copy. The other refusals (no candidate, no mirror, incomplete mirror, chain moved, every candidate unanchored) write nothing. memory_verify always writes the refusal; kint verify --no-write skips it.
kint verify#
kint [--tenant TENANT] [--db PATH] verify QUERY [--limit N] [--json] [--no-write]| argument | default | effect |
|---|---|---|
QUERY | required | the text Sibyl searches for |
--limit | 5 | how many hits Sibyl returns and kint checks |
--json | off | print the whole result as JSON instead of the lines below |
--no-write | off | do not write a refusal entity |
--tenant | KINT_TENANT, then credentials.json, then Sibyl's default | the Sibyl tenant (before the subcommand) |
--db | $SIBYL_MEMORY_DB or ~/.sibyl-memory/memory.db | the Sibyl store (before the subcommand) |
Before it verifies, it reads the chain head for the connected owner (not under KINT_OFFLINE=1, and not on a machine that is not connected). When that read fails it prints kint: chain head unavailable (<error, URLs redacted>); verifying against the local mirror only to stderr and carries on. Without --json it prints query:, then sibyl verdict: <code> hits: <n>, one line per checked hit (status, row, reason; none when verify refused before checking rows), then DECISION: PROCEED: ... or DECISION: REFUSE: ..., then the refusal entity if one was written. The exit code is 0 on proceed and 1 on refuse. With --json it exits 0 either way, so read decision.
memory_verify#
The MCP tool runs the same function inside kint-server.
| parameter | type | default |
|---|---|---|
query | string | required |
limit | integer | 5 |
It returns ok: true with the result, or ok: false with error (the exception type) and message if verify raised.
| field | content |
|---|---|
query | the query as given |
verdict | kint's copy of Sibyl's verdict, shown above |
decision | proceed or refuse |
reason | one line, as in the decision list |
hits | per hit, as Sibyl returned it: tier, key, category, snippet, rank, ts |
checks | per hit: tier, key, category, status, local_leaf, anchored_leaf, anchored_seq, anchored_block, anchored_tx, head_seq, head_block, rows_root, proof, proof_ok, reason; empty when verify refused before checking rows |
refusal_entity | kint_refusal/<name> when a refusal was written, otherwise null |
chain_head | {"seq", "digest", "block"} as read for this call; null when it could not be read, under KINT_OFFLINE=1, or when verify refused before comparing it |
What the agent believed at an earlier block#
Sibyl's store holds a row's current text. The epochs on Base hold every version that was anchored. memory_history and kint history list them, oldest first, each with the block of the epoch that carried it.
That block is an upper bound. The version existed no later than block N: it was written on some machine before the push, and the chain says nothing about how long before. It is never a wall-clock "as of". With a block given, you get the newest entry anchored at or before that block (a deletion entry when the row was deleted by then), or null if the row had no anchored version by then.
Versions come from the local epoch cache, ~/.kint/epochs/<first 16 hex of the space>/, which keeps the decrypted plaintext of every epoch this machine pushed, or pulled and could open. History makes no RPC call. A cold start stops at the newest snapshot epoch, so a freshly restored machine sees versions from that snapshot on, and a later pull that stops at a newer snapshot skips the diff epochs before it. kint pull --full (or memory_pull with full) walks past snapshots and caches every epoch this machine has not decrypted, those skipped ones included. Epochs sealed under a data key this machine does not hold, after a kint rekey, are reported by the pull and stay closed, so their versions do not appear.
A snapshot re-anchors every row. Its entry is marked snapshot: true and is dropped when the row did not change, so a compaction or a rotation never invents a version. A deletion shows as its own entry.
kint history#
kint [--tenant TENANT] history {entity,state,reference} KEY [--category CATEGORY] [--block N]KEY is the entity name, state key or reference key. --category is part of an entity's identity: without it an entity never matches and nothing prints. State and reference rows have no category. Each version prints as epoch <seq> block <= <block> leaf <16 hex>: <stored body>, a deletion as epoch <seq> block <= <block>: DELETED. With --block N it prints the version live at that block as JSON, or null.
memory_history#
| parameter | type | default |
|---|---|---|
tier | string: entity, state or reference | required |
key | string | required |
category | string, required for entities | null |
block | integer | null |
Without block it returns {"ok": true, "versions": [...], "count": n}. With block it returns {"ok": true, "at_block": N, "version": ...}, where version is null when nothing was anchored by then. Each version carries seq, block, tx, leaf (full hex), body (the stored text), status, deleted: false, and snapshot: true on a snapshot entry; a deletion carries seq, block, tx and deleted: true.
Read MCP tools next.
Source: src/kint/verify.py, src/kint/canon.py, src/kint/export.py, src/kint/epoch.py, src/kint/server.py, src/kint/cli.py, and Sibyl's sibyl_memory_client/verdicts.py in Sibyl-Memory.