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:

codemeaning (from Sibyl's explain)
okrows matched; the only code on a non-empty result
no_matchthe query ran with every gate armed and matched nothing, or carried no searchable terms
empty_storethe store holds no searchable rows for this tenant
abstained_onone content word in the query appears nowhere in the store and blocks the whole query
negation_abstainthe query is negated and full-text search cannot honour a negation
gatedcandidates 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:

tierlookupcolumns hashed
entitycategory and namecategory, name, status, body
statedocument_keydocument_key, body
referencedoc_keydoc_key, body, metadata
journalthe event id the search returnedts, 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 | extra

updated_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 verify on stderr, memory_verify in the server's log.

Four statuses per hit#

statuswhenreason
verifiedthe stored leaf equals the anchored leaf and the proof reproduces the rootstored text matches the leaf anchored on Base
driftedthe stored leaf differs from the anchored leafthe 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>)
driftedthe leaves match but the proof failsleaf matches but the inclusion proof against the anchored rows_root failed
unanchoredthe row is in the store but not in the mirrorthis row was written after the last anchored epoch; nothing on the chain vouches for it yet (push first)
missingthe key the search returned no longer reads backthe 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:

  1. Sibyl's verdict is not ok, or there are no hits: refuse with no ranked candidate to act on: Sibyl's verdict is <code>.
  2. There is no mirror file at all (normally a machine never connected under this KINT_HOME): refuse with this 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 back unanchored and step 6 refuses.
  3. 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 is epochs <list> could not be applied on the last pull when a pull stopped at a gap, or the local mirror's root does not equal the rows_root anchored on the chain when the replayed rows did not reproduce the anchored root.
  4. 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.
  5. Any hit is drifted or missing: 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.
  6. No hit is verified and at least one is unanchored: refuse with every candidate is unanchored: nothing on the chain vouches for it yet; push, then verify.
  7. 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:

fieldvalue
categorykint_refusal
name<tier>-<category or none>-<key>-<unix seconds>, cut to 200 characters
statusrefused
bodyrefused (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]
argumentdefaulteffect
QUERYrequiredthe text Sibyl searches for
--limit5how many hits Sibyl returns and kint checks
--jsonoffprint the whole result as JSON instead of the lines below
--no-writeoffdo not write a refusal entity
--tenantKINT_TENANT, then credentials.json, then Sibyl's defaultthe Sibyl tenant (before the subcommand)
--db$SIBYL_MEMORY_DB or ~/.sibyl-memory/memory.dbthe 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.

parametertypedefault
querystringrequired
limitinteger5

It returns ok: true with the result, or ok: false with error (the exception type) and message if verify raised.

fieldcontent
querythe query as given
verdictkint's copy of Sibyl's verdict, shown above
decisionproceed or refuse
reasonone line, as in the decision list
hitsper hit, as Sibyl returned it: tier, key, category, snippet, rank, ts
checksper 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_entitykint_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#

parametertypedefault
tierstring: entity, state or referencerequired
keystringrequired
categorystring, required for entitiesnull
blockintegernull

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.

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.