feat(fof-layer1): receive-path scan populates vouch_keys_received

Wires the receive side of Layer 1 vouch distribution:

- profile::scan_vouch_grants_for_all_personas reads the VouchGrantBatch
  from an incoming profile post, trial-decrypts each wrapper against
  every persona's X25519 private scalar, and inserts successful unlocks
  into vouch_keys_received. Idempotent via the bio_scan_cache.

- apply_profile_post_if_applicable now calls the scan before the
  timestamp-based last-writer-wins short-circuit on display_name/bio.
  A profile post that arrives "older" than what we've stored can still
  carry vouch grants we haven't seen — bio_epoch is the actual
  freshness signal for the wrapper batch.

- Follow-gated per the spec: skipped if the bio author isn't in
  follows. Self-authored posts skipped (we already have our own V_me).

- storage::is_follow helper added (cheap COUNT membership check).

Two new integration tests cover the wire:

- vouch_grant_end_to_end_via_bio_post: Bob's signed profile post
  carries a real wrapper for Alice + 7 dummies; Alice's keyring picks
  up V_bob and the scan cache records the hit.

- vouch_grant_skipped_for_non_followed_author: same post, but Alice
  doesn't follow Bob → no scan, no keyring entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 01:44:54 -04:00
parent 3ee5c30ad2
commit d1afcec26a
2 changed files with 254 additions and 0 deletions

View file

@ -1280,6 +1280,14 @@ impl Storage {
Ok(())
}
/// Cheap membership check against the `follows` table.
pub fn is_follow(&self, node_id: &NodeId) -> anyhow::Result<bool> {
let n: i64 = self.conn.prepare(
"SELECT COUNT(*) FROM follows WHERE node_id = ?1",
)?.query_row(params![node_id.as_slice()], |row| row.get(0))?;
Ok(n > 0)
}
pub fn remove_follow(&self, node_id: &NodeId) -> anyhow::Result<()> {
self.conn.execute(
"DELETE FROM follows WHERE node_id = ?1",