feat(fof-layer5): unlock cache + retry sweep + author-direct fast path

Three new tables + cache/sweep wiring per docs/fof-spec/
layer-5-prefilter-and-cache.md:

vouch_unlock_cache (reader_persona, author) -> winning V_x
- Records the V_x that last unlocked a post from this author. Next
  post from same author: hot path is 1 HMAC + 1 AEAD attempt.

vouch_unreadable_posts (reader_persona, post_id)
- Queue of FoF posts that no held V_x currently unlocks. Swept when
  a new V_x lands in the persona's keyring.

own_fof_post_ceks (author_persona, post_id) -> (CEK, slot_binder_nonce)
- Author-direct decrypt fast path. Populated at publish so authors
  skip wrap-slot trial entirely when reading their own posts.

find_unlock_for_post:
- Cache fast path: look up winning V_x, prefilter, single AEAD.
- Full scan fallback on cache miss; record cache hit on success;
  record unreadable on full miss.

read_fof_closed_body:
- Author-direct fast path: lookup_own_fof_post_cek before trial-unlock.

sweep_unreadable_on_new_v_x:
- Walks ALL unreadable for the persona (the new V_x can unlock posts
  authored by anyone — the V_x owner may be a chain-link in some
  third party's keyring). Wired into the vouch-grant scan path so
  every new V_x triggers a sweep automatically.

Both FoF publish paths (Mode 1 + Mode 2) now cache CEK at publish.

Bug fix found by the sweep test: storage::get_post_with_visibility
wasn't loading fof_gating_json (always returned None). Fixed; reader
paths through that function now see the gating block.

16 fof:: tests pass (2 new: cache populates+hits, sweep after V_x
arrival). 150 total tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 19:29:12 -06:00
parent ce710a6596
commit 12a305889e
4 changed files with 547 additions and 41 deletions

View file

@ -1079,6 +1079,8 @@ impl Node {
// FoF Layer 4: persist provenance so cascade-revocation can
// resolve "which pub_x's on which of my posts were sealed
// under V_me epoch N" later.
// FoF Layer 5: cache the CEK + slot_binder_nonce for author-
// direct decrypt without trial-unlocking on read.
{
let storage = self.storage.get().await;
for entry in &provenance {
@ -1087,6 +1089,14 @@ impl Node {
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
// Recover slot_binder_nonce via the Post we just built — it
// lives inside fof_gating.
if let Some(gating) = post.fof_gating.as_ref() {
let _ = storage.cache_own_fof_post_cek(
&self.default_posting_id, &post_id,
&cek, &gating.slot_binder_nonce,
);
}
}
Ok((post_id, post, visibility, cek))
@ -1111,21 +1121,29 @@ impl Node {
}
let gating = match post.fof_gating.as_ref() {
Some(g) => g,
None => return Ok(None), // invariant violation; treat as opaque
None => return Ok(None),
};
let slot_binder_nonce = gating.slot_binder_nonce;
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
Some(u) => u,
None => return Ok(None), // we're not in the FoF set
// FoF Layer 5: author-direct fast path. If this device authored
// the post, the CEK was cached at publish time; skip the
// wrap-slot trial entirely.
let (cek, slot_binder_nonce) = if let Some((cek, nonce)) =
storage.lookup_own_fof_post_cek(&post.author, post_id)?
{
(cek, nonce)
} else {
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
Some(u) => u,
None => return Ok(None),
};
(unlock.cek, gating.slot_binder_nonce)
};
drop(storage);
// Decode the base64-wrapped ciphertext + decrypt.
let body_ct = base64::engine::general_purpose::STANDARD
.decode(post.content.as_bytes())
.map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?;
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &unlock.cek, &slot_binder_nonce)?;
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &cek, &slot_binder_nonce)?;
Ok(Some(plaintext))
}
@ -1187,6 +1205,10 @@ impl Node {
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
// FoF Layer 5: cache CEK for author-direct decrypt.
let _ = storage.cache_own_fof_post_cek(
&self.default_posting_id, &post_id, &cek, &slot_binder_nonce,
);
}
self.update_neighbor_manifests_as(