feat(fof-layer4): provenance table + pure V_me rotation + cascade revoke

Lays the foundation for Layer 4 lifecycle operations:

Storage (own_post_slot_provenance):
- New author-local table mapping (post_id, slot_index) to
  (v_x_owner, v_x_epoch, pub_x). Populated at FoF post-publish.
  Used by cascade revocation to find the pub_x's that need revoking
  when a V_me epoch is retired. Never on the wire.
- record_post_slot_provenance + list_provenance_for_v_x_epoch APIs.

fof::build_fof_comment_gating now returns RealSlotProvenance entries
for each real (non-dummy) slot it sealed. Owner = persona who issued
the V_x (author's own persona_id for self-slot). Both Mode 1 and
Mode 2 publish paths persist provenance after compute_post_id.

Node API:
- rotate_v_me() — pure rotation. Generates next V_me epoch in
  vouch_keys_own (old epoch retained, is_current=0), republishes bio
  for existing vouch targets. Returns new epoch. Used for periodic
  refresh / leak response; doesn't revoke anyone.
- cascade_revoke_v_me_epoch(epoch, reason) — for every post the
  author authored where slots were sealed under (self, epoch),
  publish a per-pub_x revocation diff via revoke_fof_commenter. The
  existing Layer 2 cascade-delete then sweeps locally-stored comments.
  Returns the count of revocations published.

These combine to give the spec's "rotation + optional cascade" UX:
rotate first (cheap, grandfathers old posts), then cascade if the
user wants to actively cut off old-content access.

13 fof tests pass (new: fof_gating_real_slot_provenance asserting
provenance entries match real slots' pub_x values).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:17:47 -06:00
parent 66b78041fc
commit c0de21d37b
3 changed files with 316 additions and 25 deletions

View file

@ -1065,6 +1065,7 @@ impl Node {
))?
};
let cek = built.cek;
let provenance = built.real_slot_provenance.clone();
let (post_id, post, visibility) = self.create_post_inner(
&self.default_posting_id,
&self.default_posting_secret,
@ -1073,6 +1074,20 @@ impl Node {
attachment_data,
Some(built.gating),
).await?;
// 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.
{
let storage = self.storage.get().await;
for entry in &provenance {
let _ = storage.record_post_slot_provenance(
&self.default_posting_id, &post_id, entry.slot_index,
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
}
Ok((post_id, post, visibility, cek))
}
@ -1132,6 +1147,7 @@ impl Node {
};
let cek = built.cek;
let slot_binder_nonce = built.slot_binder_nonce;
let provenance = built.real_slot_provenance.clone();
// Encrypt + pad body under the gating CEK. Output is base64'd
// so it can live in Post.content (which is a String).
@ -1162,6 +1178,13 @@ impl Node {
&PostVisibility::FoFClosed,
&VisibilityIntent::Public,
)?;
// FoF Layer 4: persist provenance for cascade-revoke.
for entry in &provenance {
let _ = storage.record_post_slot_provenance(
&self.default_posting_id, &post_id, entry.slot_index,
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
);
}
}
self.update_neighbor_manifests_as(
@ -1886,6 +1909,84 @@ impl Node {
Ok(())
}
/// FoF Layer 4: pure V_me rotation. Generates a new V_me epoch for
/// the default persona without revoking any vouchee. Republishes
/// the persona's bio post under the new key for every current
/// target. Used for periodic refresh or leak response (combined
/// with `cascade_revoke_v_me_epoch` for old-content cleanup +
/// `key_burn_post` for leaked-key scenarios).
///
/// Returns the new epoch number.
pub async fn rotate_v_me(&self) -> anyhow::Result<u32> {
use rand::RngCore;
let (default_id, display_name, bio, avatar_cid, posting_secret, new_epoch) = {
let storage = self.storage.get().await;
let Some(default_id) = storage.get_default_posting_id()? else {
anyhow::bail!("no default posting identity");
};
let pi = storage.get_posting_identity(&default_id)?
.ok_or_else(|| anyhow::anyhow!("default posting identity missing"))?;
let profile = storage.get_profile(&default_id)?;
let (name, bio, avatar) = match profile {
Some(p) => (p.display_name, p.bio, p.avatar_cid),
None => (pi.display_name.clone(), String::new(), None),
};
let next_epoch = storage.current_own_vouch_key(&default_id)?
.map(|(e, _)| e + 1)
.unwrap_or(1);
let mut new_key = [0u8; 32];
rand::rng().fill_bytes(&mut new_key);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?;
(default_id, name, bio, avatar, pi.secret_seed, next_epoch)
};
// Republish bio so existing vouch targets receive the new key.
self.publish_profile_post_as(
&default_id, &posting_secret, &display_name, &bio, avatar_cid,
).await?;
Ok(new_epoch)
}
/// FoF Layer 4: cascade revocation. For every FoF post authored by
/// the default persona where slots were sealed under V_me at
/// `retired_epoch`, publish a per-pub_x revocation diff. Existing
/// stored comments by those pub_x's are cascade-deleted via the
/// standard apply_fof_revocation path.
///
/// Returns the number of post-level revocations published.
/// Typically called after `rotate_v_me` when the user wants to
/// retire access for vouchees they no longer want commenting on
/// old posts. Optional — by default rotation grandfathers old
/// posts.
pub async fn cascade_revoke_v_me_epoch(
&self,
retired_epoch: u32,
reason_code: u8,
) -> anyhow::Result<usize> {
// Look up all (post_id, pub_x) pairs sealed under (self, retired_epoch).
let pairs = {
let storage = self.storage.get().await;
storage.list_provenance_for_v_x_epoch(
&self.default_posting_id,
&self.default_posting_id,
retired_epoch,
)?
};
let mut published = 0usize;
for (post_id, _pub_x, slot_index) in pairs {
// Use the existing per-post revocation helper. It signs +
// applies locally + propagates.
if self.revoke_fof_commenter(post_id, slot_index, reason_code).await.is_ok() {
published += 1;
}
}
Ok(published)
}
/// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS
/// the rotation primitive. The new V_me_epoch is generated and the
/// bio post is republished with wrappers for every remaining target