One change set, one transaction, one digest on chain.

An epoch is the unit kint writes to Base: a set of rows, compressed, padded, encrypted and sent as the calldata of one push transaction.

What an epoch is#

This page covers what goes inside one, the exact bytes of the envelope, how a reader finds epochs again, and what each one costs.

Every epoch belongs to one owner and one space. The space is keccak256("kint-space-v1" || tenant), so each Sibyl tenant has its own chain of epochs under the same wallet. Epochs are numbered from 1 (seq), and each one names the digest of the epoch before it (prev, 32 zero bytes for the first).

For each (owner, space), EpochAnchor keeps the head: the latest digest, its seq and the block it landed in. push(owner, space, prev, ct) reverts with StaleHead(expected, got) unless prev equals the current head digest, so the chain only ever grows at the end and two writers cannot both extend the same head.

A push runs after Sibyl's write has returned, never inside it; the export reads the store read-only. It happens when you run kint push, when the agent calls memory_push, when kint-server's watcher fires (after KINT_QUIET_SECONDS of quiet, default 300, or on its size trigger), and best effort on exit. When nothing changed, push anchors nothing and says nothing to push: the store matches the last anchored epoch.

Diff epochs and snapshot epochs#

diff epochsnapshot epoch
rowsonly the rows whose leaf changed since the last anchored epochevery row in the store (the full state)
deletedrows removed since the last anchored epochthe same list, kept for history only
header flags0x00FLAG_SNAPSHOT (0x02)
plaintext keyabsent"snapshot": true, the last key
rows_rootroot of the mirror plus this epoch's changesroot of exactly its own rows
written bykint push, memory_push(), the watcher, the exit hookkint compact, memory_push(snapshot=true), kint rekey

A snapshot is written even when the diff is empty; that is the point of kint compact and of a key rotation. A pull without --full stops walking at the newest snapshot, so restore cost is bounded by the size of the memory, not its history. kint pull --full (and memory_pull(full=true)) walks past snapshots, and afterwards caches, for history, every epoch from the head back to the first that this machine has not decrypted yet, including the diff epochs an earlier pull jumped when it stopped at a newer snapshot. The deletions a snapshot carries are already absent from its rows; history and at_block use them to record when a row disappeared.

The other defined flag, 0x01 (a signature wrap salted with a passphrase), is set by no kint writer. A reader must not branch on it, and unknown bits are reserved: ignore them, never refuse on them.

Why the flag and the key must agree#

The flags byte sits in the header, which a reader parses before it decrypts anything: that is how a walk knows where to stop. The flags are not part of the AAD (below), so the AES-GCM tag says nothing about them. A writer holding a session key but not the data key can still set that bit. The plaintext "snapshot" key lives inside the ciphertext, so it is covered by the tag.

kint acts on a snapshot only when both say the same thing. On a pull, an epoch whose header flag and plaintext key disagree is a gap: the pull stops there with the reason snapshot flag disagrees between header and plaintext, and the mirror stays at the last epoch it applied. If a walk stopped at an epoch that claims to be a snapshot and that epoch does not open, kint logs pull: epoch N claims to be a snapshot but cannot be applied; walking the full history instead, applies the epochs before it, and reports it by name. The browser core runs the same check (assertEpochConsistent) and the same provisional stop (coldStartEvents, which names the refused epoch in refusedSnapshotSeq); see Browser core.

The plaintext key is present only on a snapshot, so every ordinary epoch stays byte-identical to the v1 format.

The plaintext#

Before anything is compressed, an epoch is one JSON document, serialised with no spaces (separators=(",", ":"), ensure_ascii=False, UTF-8) in this key order. Abridged, with placeholders where values vary; the row shown is the demo tenant's release rule (rules/release-gate), the row epoch 2 rewrote:

{
  "v": 1,
  "tenant": "kint-demo",
  "space": "<64 hex: keccak256(\"kint-space-v1\" || tenant)>",
  "seq": 2,
  "prev": "<64 hex: digest of epoch 1>",
  "rows": [
    {"tier": "entity", "key": "release-gate", "category": "rules", "status": "<stored status>",
     "body": "<the exact stored TEXT>", "meta": null, "ts": "<stored updated_at>",
     "evaluated": null, "acted": null, "forward": null, "extra": null}
  ],
  "deleted": [["<tier>", "<category or null>", "<key>"]],
  "rows_root": "<64 hex: root of the full state after this epoch>",
  "n_rows": "<integer: rows in the full state after this epoch>",
  "created_at": "<UTC, %Y-%m-%dT%H:%M:%SZ>"
}
  • rows are wire rows: tier, key, category, status, body, meta, ts, evaluated, acted, forward, extra. The local rowid and journal uuid are dropped.
  • body, meta and the journal fields are the exact stored TEXT from Sibyl's tables, never re-serialised. Sibyl's dumps() keeps insertion order on purpose; a re-serialised hash would refuse every row on the next machine.
  • deleted entries are [tier, category or null, key].
  • n_rows is the total row count after the epoch. created_at is the writer's own clock; kint states when a row existed as a block height upper bound instead.

Leaves and rows_root#

Every row has a canonical id, tier\0category\0key (empty category outside entities), and a leaf. With lp(x) = uint32(len) || utf8(x) (big-endian) and lp(null) = 0xFFFFFFFF:

entity, state, reference:
  leaf = keccak256("kint-leaf-v1" || lp(tier) || lp(category) || lp(key) || lp(status) || lp(body) || lp(meta))

journal:
  leaf = keccak256("kint-leaf-v1" || lp("journal") || lp(null) || lp(key) || lp(null)
                   || lp(ts) || lp(evaluated) || lp(acted) || lp(forward) || lp(extra))
  key  = hex(keccak256("kint-journal-key-v1" || lp(ts) || lp(evaluated) || lp(acted) || lp(forward) || lp(extra)))

Timestamps Sibyl regenerates on replay (updated_at on entities, state and reference) are not in the leaf. The journal ts is content and is. Journal rows get a new uuid on replay, so their key is their content; a second identical event gets :1, a third :2, assigned in rowid order.

rows_root is the merkle root over the leaves of the full state after the epoch, sorted by canonical id. Pairs hash as keccak256(left || right), an odd last node is carried up unchanged, and an empty state has the root keccak256("kint-empty-v1"). Because it covers the whole state, a single row can be proven against any one epoch's rows_root with a merkle path; see Verify before acting.

Sealing the payload#

  1. Compress. gzip at level 9 with mtime 0, so the same plaintext compresses to the same bytes.
  2. Pick a bucket. The smallest of 4096, 8192, 16384, 32768, 65536 or 98304 bytes that holds the compressed length plus 4. The choice ratchets per space: an epoch never uses a smaller bucket than the one before it.
  3. Pad. padded = uint32(len(gz)) || gz || zeros up to the bucket.
  4. Encrypt. AES-256-GCM under the space's data key, a fresh random 12-byte nonce, and the AAD below. The ciphertext is exactly bucket + 16 bytes (the GCM tag).
  5. Frame. blob = header || ciphertext. That blob is the ct argument of push.

Only the bucket is public. The true compressed length is the first four bytes of the padded payload, inside the ciphertext.

The header#

Big-endian, byte-exact. The fixed part is 59 bytes, and each wrap adds 77.

offsetbytesfieldcontents
01version0x01; anything else is refused as unsupported envelope version
11flags0x02 snapshot; 0x01 defined, never set
212gcm_noncethe AES-GCM nonce of the payload
1432rows_rootmerkle root of the full state after this epoch
468dek_idHMAC-SHA256(DEK, "kint-dek-id-v1")[:8]
544lenBucketthe padded length (one of the six buckets)
581n_wrapshow many wraps follow
5977 eachwrapskek_kind (1), kek_tag (16), wrap_nonce (12), wrapped_dek (48)

Each wrap is the data key encrypted under one key-encryption key: AES-256-GCM(KEK, wrap_nonce, DEK, aad = "kint-wrap-v1" || kek_kind || kek_tag). Kinds are 0x01 wallet signature, 0x02 passphrase, with 0x03 and 0x04 reserved. A reader finds its wrap by comparing kek_tag, never by trial decryption, because GCM does not commit to its key. Every epoch carries its own wraps, which is why a wiped machine gets the data key out of the head epoch's header. Keys and custody covers how each KEK is derived.

Opening checks the ciphertext length against lenBucket + 16 and the dek_id against the key in hand (dek_id mismatch: wrong data key for this epoch) before it decrypts.

The AAD#

aad = keccak256(abi.encode(
    uint256 chainId,      8453
    address owner,
    bytes32 space,
    uint64  seq,
    bytes32 prev,
    uint32  lenBucket,
    bytes32 rows_root,
    bytes8  dek_id))

Chain id, owner and space pin an epoch to one vault on Base. seq and prev pin it to one position, so a valid ciphertext cannot be replayed at another. lenBucket, rows_root and dek_id bind the header fields a reader acts on to the payload. What the AAD leaves out: the version byte, the flags byte, the nonce itself, and the wraps, which carry their own AAD.

Why calldata#

For each (owner, space) the contract stores a digest, a sequence number and a block number, and its Epoch event carries the digest. The ciphertext itself is never copied to storage or into an event: it stays in the calldata of the push transaction that carried it there. Base charges those bytes as calldata: L2 gas under the EIP-7623 floor, plus the L1 data fee. The contract's own work (the head write, the event, the keccak) is flat, 61,409 gas at 4 KB and 139,387 gas at 100 KB, and the calldata floor absorbs it at every bucket, so the calldata is the bill. A root alone on Base is a receipt for data you still have to keep somewhere else; here the calldata is the store.

The Epoch event and the prevBlock walk#

event Epoch(address indexed owner, bytes32 indexed space, address indexed writer,
            uint64 seq, bytes32 prev, bytes32 digest, uint64 prevBlock);

writer is the address that sent the push (the owner or a session key). digest is keccak256(ct). prevBlock is the block the previous head landed in, 0 for the first epoch. That field is what lets a reader walk backwards with one exact-block query per epoch:

  1. Read head(owner, space) for the newest seq and its block.
  2. Call eth_getLogs with fromBlock and toBlock both set to that block, filtered by the contract, the Epoch topic, the owner and the space, and take the event with the wanted seq. If it is missing, the walk stops with no Epoch event for seq N at block B; the RPC may be pruned or lying.
  3. Move to prevBlock and seq - 1. Repeat until prevBlock is 0, the walk reaches epochs this machine already applied, or, without --full, it reaches a snapshot.

Walking back from epoch 2 of the demo tenant takes two queries: block 51081880 for epoch 2, whose prevBlock points to 51081867 for epoch 1, whose prevBlock is 0. No query in the walk spans a block range, so no RPC range cap is hit. For each event, kint first checks prev continuity. Then it takes the ciphertext from the local cache, or fetches the transaction, requires it to call push on EpochAnchor with the same owner, space and prev, and requires keccak256(ct) to equal the event's digest. Only then does it open the epoch.

The writer checks the chain too. Before sending it compares the chain head with what this machine last saw and refuses if another machine pushed first (the owner's override below is the one exception). After the transaction has its confirmations (kint push --confirmations, default 2), it reads the event back at the receipt's block and stops with epoch N landed but the Epoch event digest does not match what was sent if they differ.

If the transaction lands but the wait for its receipt or its confirmations fails (a timeout, an RPC that drops), this machine never records the epoch. Its next push then reads its own epoch as another machine's push, and its next pull calls the rows it sent a fork. kint pull --discard-local gets out of it: the chain already carries that epoch, so the restore brings those rows back, and only rows changed after that push stay behind in the .kint-backup- copy.

An epoch nobody can open#

push checks who writes, never what: any authorized writer can append any non-empty bytes as the next epoch. A session key holds no data key, so an epoch appended with a leaked one opens for nobody, and it sits in the chain like any other. kint deals with it in three places.

  • Pull. A walk that goes past snapshots (--full) can meet an epoch this machine cannot open: its header does not parse, no key here opens it, or AES-GCM refuses it. When a later snapshot in the same walk opens, the pull resumes there, because a snapshot's rows are the whole state. It reports the epoch as skipped with superseded_by and ends complete. An epoch the RPC would not serve, or one that opened and contradicts the chain, is never walked past: nobody has read what is in it. A pull without --full starts at the newest snapshot and never opens what lies before it.
  • Connect and rekey. When the head epoch does not parse as a kint header, they take the wraps from the newest epoch that does, walking back through prevBlock at most 32 epochs. kint rekey logs both steps.
  • The owner's snapshot. Anywhere else the pull stops at the epoch, and push and verify refuse on that machine. The push refusal ends by naming kint compact --over-skipped. Run where the data key is cached, after a pull that recorded the gap, that command anchors a snapshot of this machine's store on top of the chain head, whatever sits between; kint rekey --over-skipped does the same while rotating the key. Every later pull stops at that snapshot and never needs the bad epoch.

The override is scoped on purpose: a snapshot, from a terminal, on a machine whose last pull reported an epoch it could not apply. No MCP tool takes it. It logs push: OVERRIDE, anchoring a snapshot on top of chain head seq N (<12 hex>) although this machine stopped at seq M and could not apply epoch(s) [K]. The snapshot carries this machine's store as it is, so run it where the memory is the one to keep, and revoke the leaked key first (Keys and custody) or it can append again. Connect and rekey step over a header that does not parse and nothing else: a bad epoch with a well-formed header whose wraps none of your keys open still stops them until the owner's snapshot sits on top of it.

Splitting a large diff#

One epoch carries at most 90 KB (92,160 bytes) of compressed rows, measured rather than estimated: kint gzips the candidate chunk's rows and deletions and adds 512 bytes of headroom. Rows are added in order; when the next row would cross the limit, the chunk closes and a new epoch starts. Deletions travel in the first epoch. Each chunk is its own transaction, seq and prev chain them in order, and each carries the rows_root of the state after it.

A single row that does not fit on its own is refused by name before anything is sent (the category and its slash appear only for entities):

row <tier> <category>/<key> compresses to more than 90 KB on its own and cannot fit one epoch; shrink or split it in Sibyl (push refuses until then, nothing else is blocked)

A snapshot is always a single epoch. When the full state is over the limit, kint compact refuses with the full state (N rows) compresses to S bytes, more than the 92160 bytes one epoch can carry, and snapshots are single-epoch, and ordinary epochs keep working. --dry-run on kint push or kint compact reports what would be anchored and sends nothing.

What it costs#

Base charges calldata under EIP-7623's floor, max(standard, 21000 + 10 * (zeros + 4 * nonzeros)). Ciphertext is almost all nonzero bytes, so that comes to 40 gas per byte, and the floor absorbs the contract's own execution at every bucket. Measured on a Base fork at block 51,081,018, the receipt's gasUsed equals the floor exactly:

bucketcalldata bytesgasUsed
4 KB4,356191,040
8 KB8,452355,390
16 KB16,644681,840
32 KB33,0281,336,090
64 KB65,7962,642,640
96 KB98,5643,949,670

On top of that comes the L1 data fee, reported as l1Fee in the receipt. All in, a 4 KB epoch costs about 0.000002 ETH on Base. Epoch 1 of the demo tenant carried 14 rows in the 4 KB bucket (tx, block 51081867); epoch 2 followed at block 51081880 (tx). kint records the real gasUsed, l1Fee and total from every receipt in the push report. Restoring and reading never send a transaction.

Where the bytes live#

The ciphertext lives only in transaction calldata. Reading it back goes through eth_getTransactionByHash, which needs a node that keeps its transaction index, and the walk needs eth_getLogs. Base's data availability on L1 is the blob retention window, not forever.

So kint keeps its own copy. Every epoch a push sends or a pull fetches goes into a local cache under KINT_HOME (default ~/.kint):

~/.kint/epochs/<first 16 hex of the space>/
├── 00000001.bin          the ciphertext, byte for byte as sent
├── 00000001.json         the decrypted plaintext
├── 00000001.meta.json    seq, digest, prev, block, tx, bucket, rows_root, writer
└── 00000002.bin ...

A pull reads the cache first and trusts a cached blob only when its keccak equals the digest in the Epoch event; anything else is fetched from calldata again. The decrypted .json files also feed memory_history and the read of a row at an earlier block. The directory sits outside every path Sibyl's cap accounting walks, and kint volunteers its size into that cap.

Note. The cache is a backup of the ciphertext, not a replacement for the chain. The chain still decides which epochs exist and which digest each one must have; the cache only saves a fetch on a machine that already holds it. Files are written with mode 0600, and KINT_HOME itself is mode 0700.

Source: src/kint/crypto.py, src/kint/canon.py, src/kint/push.py, src/kint/epoch.py, src/kint/chain.py, src/kint/pull.py, contracts/src/EpochAnchor.sol, contracts/README.md, docs/frontend-contract.md.