Grants and epochs
A grant gives one app a key to one namespace. Revoking moves the namespace to a new epoch, so nothing sealed afterwards opens for that app.
Grant
The reading app calls createGrantee(), which generates a P-256 key pair and never touches the passkey. Its public key travels to the owner over any channel. s.grant(ns, publicKey, { label }) wraps the namespace's current epoch key to that public key with HPKE (RFC 9180, DHKEM P-256, HKDF-SHA256, AES-128-GCM) and records the grant in the registry. The wrapped key is also in the transaction's Granted event, which is why a grant is portable: fetchGrant rebuilds it from the transaction hash alone. Two apps shows the handoff.
Epoch
The registry stores one counter per owner and namespace hash. The namespace path is not on chain, only its hash. seal() reads the counter and encrypts under that epoch's key. revoke() deactivates the grant and increments the counter in one transaction.
registry 0xd6c895f5c9254dc6eaafc335fff0930fd7012e6d Monad testnet, chain 10143
epochOf(owner, nsHash) view, the current epoch
grant(nsHash, keyHash, key, label) emits Granted(owner, nsHash, grantId, epoch, ...)
revoke(nsHash, grantId) emits Revoked(owner, nsHash, grantId, newEpoch)
grantOf(grantId) view, owner, nsHash, epoch, keyHash, activeWhat a revoked reader still holds
A grant opens exactly one epoch. After revoke, a reader's grant still opens every record already sealed at its epoch, because it already had that key: no system can take back what an app has read. Records sealed from the new epoch on use a key the revoked app cannot derive, and Reader.unseal answers GRANT_REVOKED without attempting decryption.
The demo goes one step further than the SDK requires. When Pip revokes, it seals every stored memory again at the new epoch and replaces the old ciphertext, so the next read by Fern comes back GRANT_REVOKED for all of them and Fern answers without them. Whatever Fern read before the revoke may still sit in Fern's own cache.
Revoking one grant moves every reader
The epoch belongs to the namespace, not to a grant. When the owner revokes Fern, the counter goes from 1 to 2 for everyone: every other app that holds a grant on the same namespace has a grant for epoch 1, so it stops opening records sealed from epoch 2 on, although its own grant is still active in the registry. Until it is granted again it reads what was sealed before and nothing newer.
regrant is the owner's call for the readers that should stay: it issues a fresh grant at the current epoch, one more registry transaction per reader. The revoked app is simply not regranted.
// Fern and Bob both hold grants at epoch 1.
await s.revoke(memory, fernGrant.id); // epoch 1 to 2 for the whole namespace
const fresh = await s.seal(memory, "epoch two");
await bob.reader(bobGrant).unseal(fresh); // throws GRANT_REVOKED: Bob's grant opens epoch 1 only
const again = await s.regrant(memory, bob.publicKey, { label: "Bob" }); // epoch 2
await bob.reader(again).unseal(fresh); // opens
await fern.reader(fernGrant).unseal(fresh); // still throws GRANT_REVOKED: Fern was not regrantedThis is the cost of a revoke that needs no per-record bookkeeping: one counter stops one app and every other app asks once to continue. Records already sealed are not re-encrypted, so they stay readable under the grants of their own epoch.
Who pays
The registry owner is an EOA derived from the passkey root (s.registryOwner). It sends grant, regrant and revoke, so it needs a little MON on the registry's chain; on Monad testnet the faucet sends it. Before sending, the SDK reads the owner's balance and the gas estimate. If the owner cannot pay it throws OWNER_UNFUNDED with the address and the shortfall, and nothing is sent. Both calls resolve after a successful receipt, never on an accepted hash alone.