feat(fof-layer1): schema + storage API + vouch-grant crypto primitives

Lands the foundational pieces for FoF Layer 1 (vouch primitive) per
docs/fof-spec/layer-1-vouch-primitive.md:

Schema (init_tables, CREATE TABLE IF NOT EXISTS — safe for upgrade and
fresh installs):
- vouch_keys_own: per-persona V_me history, append-only on rotation
- vouch_keys_received: per-persona inbound keyring, multi-epoch
- vouch_bio_scan_cache: short-circuits unchanged-bio re-scans
- own_vouch_targets: author-local, never on wire, drives batch assembly

Storage API: insert/list/lookup for all four tables, including
current_own_vouch_key, list_received_vouch_keys, list_vouchers_for,
record_bio_scan_result, upsert/revoke_vouch_target.

Crypto: HPKE-style seal_vouch_grant / open_vouch_grant using existing
ed25519 → X25519 derivation. Per-batch ephemeral X25519 keypair via
generate_vouch_batch_ephemeral. Wrapper is 48B (32B sealed V_me + 16B
AEAD tag). Recipient-free derivation context per spec — info string
is "itsgoin/vouch-grant/v1/{key|nonce}/<bio_post_id>". 3 unit tests
cover roundtrip + wrong-post-id + random-bytes-as-dummy.

No behavior change yet; nothing wired in. Layer 1 wire types, persona
auto-gen, publish/scan paths follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 01:29:43 -04:00
parent d7ce2f734c
commit 8a53d83306
2 changed files with 534 additions and 0 deletions

View file

@ -12,6 +12,12 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK
const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1";
/// FoF Layer 1: vouch-grant HPKE-style wrapper construction.
/// HKDF/derive_key info MUST be recipient-free (key privacy).
/// `bio_post_id` ties the wrapper to the publishing bio post.
const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key";
const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce";
/// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes.
pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] {
let signing_key = SigningKey::from_bytes(seed);
@ -193,6 +199,108 @@ pub fn unwrap_group_cek(
Ok(cek)
}
// --- FoF Layer 1: vouch-grant HPKE-style seal/open ---
//
// Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher
// publishes anonymous per-recipient wrappers inside their bio post. Each
// wrapper carries `V_me` (the voucher's symmetric key) sealed under a
// shared secret derived from ECDH between a per-batch ephemeral X25519
// keypair and the recipient's persona X25519 key.
//
// Recipient anonymity ("key privacy") is preserved because:
// 1. Wrappers carry no recipient identifier.
// 2. The KDF info string is recipient-free (only the post_id appears).
// 3. All wrappers in a batch share the same ephemeral pubkey.
//
// Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag).
// One 32B ephemeral pubkey shared across all wrappers in the batch.
/// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch.
/// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the
/// ed25519 → X25519 derivation path that the rest of the codebase uses
/// so all X25519 endpoints are produced identically.
pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) {
let mut seed = [0u8; 32];
rand::rng().fill_bytes(&mut seed);
let eph_priv = ed25519_seed_to_x25519_private(&seed);
let signing_key = SigningKey::from_bytes(&seed);
let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes();
(eph_priv, eph_pub)
}
/// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from
/// the ECDH shared secret and the publishing bio post's ID.
fn derive_vouch_grant_key_nonce(
shared_secret: &[u8; 32],
bio_post_id: &PostId,
) -> ([u8; 32], [u8; 12]) {
// Bake bio_post_id into the derivation context. Recipient-free.
let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id));
let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id));
let wrapping_key = blake3::derive_key(&key_ctx, shared_secret);
let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret);
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&nonce_full[..12]);
(wrapping_key, nonce)
}
fn hex_lower(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
/// Seal `V_me` (32B) under the recipient's X25519 pubkey using the
/// batch's ephemeral X25519 private key. Returns the 48-byte wrapper
/// `ciphertext(32) || tag(16)`.
pub fn seal_vouch_grant(
eph_priv: &[u8; 32],
recipient_x25519_pub: &[u8; 32],
bio_post_id: &PostId,
v_me: &[u8; 32],
) -> Result<Vec<u8>> {
let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub);
let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id);
let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
.map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), v_me.as_slice())
.map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?;
// ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B.
if ciphertext.len() != 48 {
bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len());
}
Ok(ciphertext)
}
/// Try to open a vouch-grant wrapper using the recipient's X25519 private
/// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e.,
/// this wrapper was not addressed to this recipient).
pub fn open_vouch_grant(
recipient_x25519_priv: &[u8; 32],
batch_eph_pub: &[u8; 32],
bio_post_id: &PostId,
wrapper_ciphertext: &[u8],
) -> Option<[u8; 32]> {
if wrapper_ciphertext.len() != 48 {
return None;
}
let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub);
let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id);
let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?;
let plaintext = cipher
.decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext)
.ok()?;
if plaintext.len() != 32 {
return None;
}
let mut v_me = [0u8; 32];
v_me.copy_from_slice(&plaintext);
Some(v_me)
}
/// Encrypt a post with a provided CEK, wrapping for recipients.
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
pub fn encrypt_post_with_cek(
@ -1347,4 +1455,71 @@ mod tests {
// Different calls produce different noise (with very high probability)
assert_ne!(random_slot_noise(64), random_slot_noise(64));
}
// --- FoF Layer 1: vouch-grant seal/open ---
fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) {
// Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring
// the production path personas use.
let mut seed = [0u8; 32];
seed[0] = seed_byte;
let priv_x = ed25519_seed_to_x25519_private(&seed);
let signing_key = SigningKey::from_bytes(&seed);
let pub_x = signing_key.verifying_key().to_montgomery().to_bytes();
(priv_x, pub_x)
}
#[test]
fn vouch_grant_roundtrip() {
let (alice_priv, _alice_pub) = make_persona_x25519(11);
let (bob_priv, bob_pub) = make_persona_x25519(22);
let bio_post_id: PostId = [7u8; 32];
let v_me: [u8; 32] = [42u8; 32];
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
// Seal for Bob
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap();
assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)");
// Bob opens it
let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper);
assert_eq!(opened, Some(v_me));
// Alice (not the recipient) cannot open it
let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper);
assert_eq!(alice_attempt, None, "non-recipient must not decrypt");
}
#[test]
fn vouch_grant_wrong_bio_post_id_fails() {
let (_, bob_pub) = make_persona_x25519(22);
let (bob_priv, _) = make_persona_x25519(22);
let real_bio_id: PostId = [1u8; 32];
let wrong_bio_id: PostId = [2u8; 32];
let v_me: [u8; 32] = [99u8; 32];
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap();
// Wrong bio_post_id derives a different key+nonce → AEAD fails.
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper);
assert_eq!(attempt, None);
// Right bio_post_id succeeds.
let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper);
assert_eq!(ok, Some(v_me));
}
#[test]
fn vouch_grant_random_bytes_fail() {
let (bob_priv, _) = make_persona_x25519(22);
let bio_post_id: PostId = [5u8; 32];
let (_, eph_pub) = generate_vouch_batch_ephemeral();
let mut junk = [0u8; 48];
rand::rng().fill_bytes(&mut junk);
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk);
assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)");
}
}

View file

@ -415,6 +415,60 @@ impl Storage {
secret_seed BLOB NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
-- FoF Layer 1: per-persona V_me history. Rows are append-only
-- on rotation (Layer 4); old epochs retained for unwrapping
-- historical wrap_slots. is_current marks the active outgoing
-- key. key_material is the 32B symmetric V_me bytes.
CREATE TABLE IF NOT EXISTS vouch_keys_own (
persona_id BLOB NOT NULL,
epoch INTEGER NOT NULL,
key_material BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
is_current INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (persona_id, epoch)
);
CREATE INDEX IF NOT EXISTS idx_vouch_keys_own_current
ON vouch_keys_own(persona_id, is_current);
-- FoF Layer 1: per-persona keyring of received vouch keys from
-- others. holder_persona_id is whose keyring this row belongs
-- to; owner_id is the persona who issued the V_x; epoch is the
-- issuer's V_x epoch. Multi-epoch retention per Layer 4.
CREATE TABLE IF NOT EXISTS vouch_keys_received (
holder_persona_id BLOB NOT NULL,
owner_id BLOB NOT NULL,
epoch INTEGER NOT NULL,
key_material BLOB NOT NULL,
received_at_ms INTEGER NOT NULL,
source_bio_post_id BLOB,
PRIMARY KEY (holder_persona_id, owner_id, epoch)
);
CREATE INDEX IF NOT EXISTS idx_vouch_keys_received_owner
ON vouch_keys_received(holder_persona_id, owner_id);
-- FoF Layer 1: short-circuit cache for re-scanning bio posts
-- that haven't changed. bio_epoch is the issuer's bio-post
-- revision counter. result=1 means a wrapper unlocked; 0 means
-- nothing for this persona.
CREATE TABLE IF NOT EXISTS vouch_bio_scan_cache (
scanner_persona_id BLOB NOT NULL,
bio_author_id BLOB NOT NULL,
bio_epoch INTEGER NOT NULL,
result INTEGER NOT NULL,
unlocked_v_x_epoch INTEGER,
scanned_at_ms INTEGER NOT NULL,
PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch)
);
-- FoF Layer 1: author-local record of who this persona has
-- vouched for. Never on the wire. Drives bio-post wrapper
-- batch assembly. current=1 means the target is in the latest
-- batch; current=0 means they were removed (revoked).
CREATE TABLE IF NOT EXISTS own_vouch_targets (
voucher_persona_id BLOB NOT NULL,
target_persona_id BLOB NOT NULL,
target_x25519_pub BLOB NOT NULL,
granted_at_ms INTEGER NOT NULL,
current INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (voucher_persona_id, target_persona_id)
);",
)?;
Ok(())
@ -4586,6 +4640,311 @@ impl Storage {
Ok(n as u64)
}
// --- FoF Layer 1: Vouch keys (own + received) ---
/// Insert a new V_me epoch for a persona. Marks it current; older
/// epochs are flipped to non-current. Append-only — old epochs are
/// never deleted by rotation (see Layer 4).
pub fn insert_own_vouch_key(
&self,
persona_id: &NodeId,
epoch: u32,
key_material: &[u8; 32],
created_at_ms: u64,
) -> anyhow::Result<()> {
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"UPDATE vouch_keys_own SET is_current = 0 WHERE persona_id = ?1",
params![persona_id.as_slice()],
)?;
tx.execute(
"INSERT OR REPLACE INTO vouch_keys_own
(persona_id, epoch, key_material, created_at_ms, is_current)
VALUES (?1, ?2, ?3, ?4, 1)",
params![
persona_id.as_slice(),
epoch as i64,
key_material.as_slice(),
created_at_ms as i64,
],
)?;
tx.commit()?;
Ok(())
}
/// Return the persona's current V_me as `(epoch, key)`, or None if not set.
pub fn current_own_vouch_key(
&self,
persona_id: &NodeId,
) -> anyhow::Result<Option<(u32, [u8; 32])>> {
let result = self.conn.query_row(
"SELECT epoch, key_material FROM vouch_keys_own
WHERE persona_id = ?1 AND is_current = 1",
params![persona_id.as_slice()],
|row| {
let epoch: i64 = row.get(0)?;
let key: Vec<u8> = row.get(1)?;
Ok((epoch, key))
},
);
match result {
Ok((epoch, key_bytes)) => {
let key: [u8; 32] = key_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid vouch key length"))?;
Ok(Some((epoch as u32, key)))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Return all V_me epochs for a persona (current + retained past).
/// Sorted newest-first. Used at unwrap time (try newest first) and
/// when a sender needs to publish multi-epoch grants.
pub fn list_own_vouch_keys(
&self,
persona_id: &NodeId,
) -> anyhow::Result<Vec<(u32, [u8; 32])>> {
let mut stmt = self.conn.prepare(
"SELECT epoch, key_material FROM vouch_keys_own
WHERE persona_id = ?1 ORDER BY epoch DESC",
)?;
let rows = stmt.query_map(params![persona_id.as_slice()], |row| {
let epoch: i64 = row.get(0)?;
let key: Vec<u8> = row.get(1)?;
Ok((epoch, key))
})?;
let mut out = Vec::new();
for r in rows {
let (epoch, key_bytes) = r?;
let key: [u8; 32] = key_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid vouch key length"))?;
out.push((epoch as u32, key));
}
Ok(out)
}
/// Insert a received vouch key into a persona's keyring. Idempotent
/// on `(holder, owner, epoch)`.
pub fn insert_received_vouch_key(
&self,
holder_persona_id: &NodeId,
owner_id: &NodeId,
epoch: u32,
key_material: &[u8; 32],
received_at_ms: u64,
source_bio_post_id: Option<&[u8; 32]>,
) -> anyhow::Result<()> {
self.conn.execute(
"INSERT OR IGNORE INTO vouch_keys_received
(holder_persona_id, owner_id, epoch, key_material, received_at_ms, source_bio_post_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
holder_persona_id.as_slice(),
owner_id.as_slice(),
epoch as i64,
key_material.as_slice(),
received_at_ms as i64,
source_bio_post_id.map(|b| b.as_slice()),
],
)?;
Ok(())
}
/// Return the full received-vouch keyring for a persona. Each row is
/// `(owner_id, epoch, key_material)`. Trial-unwrap iterates the result.
pub fn list_received_vouch_keys(
&self,
holder_persona_id: &NodeId,
) -> anyhow::Result<Vec<(NodeId, u32, [u8; 32])>> {
let mut stmt = self.conn.prepare(
"SELECT owner_id, epoch, key_material FROM vouch_keys_received
WHERE holder_persona_id = ?1
ORDER BY owner_id, epoch DESC",
)?;
let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| {
let owner: Vec<u8> = row.get(0)?;
let epoch: i64 = row.get(1)?;
let key: Vec<u8> = row.get(2)?;
Ok((owner, epoch, key))
})?;
let mut out = Vec::new();
for r in rows {
let (owner_bytes, epoch, key_bytes) = r?;
let owner: NodeId = owner_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid owner_id in vouch_keys_received"))?;
let key: [u8; 32] = key_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid key in vouch_keys_received"))?;
out.push((owner, epoch as u32, key));
}
Ok(out)
}
/// List distinct owners that have vouched for a persona (for UI
/// "Who has vouched for me"). Latest epoch per owner.
pub fn list_vouchers_for(
&self,
holder_persona_id: &NodeId,
) -> anyhow::Result<Vec<(NodeId, u32, u64)>> {
let mut stmt = self.conn.prepare(
"SELECT owner_id, MAX(epoch), MAX(received_at_ms)
FROM vouch_keys_received
WHERE holder_persona_id = ?1
GROUP BY owner_id",
)?;
let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| {
let owner: Vec<u8> = row.get(0)?;
let epoch: i64 = row.get(1)?;
let at: i64 = row.get(2)?;
Ok((owner, epoch, at))
})?;
let mut out = Vec::new();
for r in rows {
let (owner_bytes, epoch, at) = r?;
let owner: NodeId = owner_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid owner_id"))?;
out.push((owner, epoch as u32, at as u64));
}
Ok(out)
}
/// Lookup a scan-cache entry. Returns Some(unlocked_epoch) if the
/// cached result was a hit (Some(None) means the row exists as a miss).
/// Returns None if no cache row exists (scan needed).
pub fn lookup_bio_scan_cache(
&self,
scanner_persona_id: &NodeId,
bio_author_id: &NodeId,
bio_epoch: u32,
) -> anyhow::Result<Option<Option<u32>>> {
let result = self.conn.query_row(
"SELECT result, unlocked_v_x_epoch FROM vouch_bio_scan_cache
WHERE scanner_persona_id = ?1 AND bio_author_id = ?2 AND bio_epoch = ?3",
params![
scanner_persona_id.as_slice(),
bio_author_id.as_slice(),
bio_epoch as i64,
],
|row| {
let result: i64 = row.get(0)?;
let unlocked: Option<i64> = row.get(1)?;
Ok((result, unlocked))
},
);
match result {
Ok((res, unlocked)) => {
if res == 1 {
Ok(Some(unlocked.map(|e| e as u32)))
} else {
Ok(Some(None))
}
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Record a scan-cache hit (result=1) or miss (result=0).
pub fn record_bio_scan_result(
&self,
scanner_persona_id: &NodeId,
bio_author_id: &NodeId,
bio_epoch: u32,
unlocked_v_x_epoch: Option<u32>,
scanned_at_ms: u64,
) -> anyhow::Result<()> {
let result_flag: i64 = if unlocked_v_x_epoch.is_some() { 1 } else { 0 };
self.conn.execute(
"INSERT OR REPLACE INTO vouch_bio_scan_cache
(scanner_persona_id, bio_author_id, bio_epoch, result, unlocked_v_x_epoch, scanned_at_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
scanner_persona_id.as_slice(),
bio_author_id.as_slice(),
bio_epoch as i64,
result_flag,
unlocked_v_x_epoch.map(|e| e as i64),
scanned_at_ms as i64,
],
)?;
Ok(())
}
/// Upsert an outbound vouch target for a persona. `current=1` means
/// it'll be wrapped into the next bio-post batch.
pub fn upsert_vouch_target(
&self,
voucher_persona_id: &NodeId,
target_persona_id: &NodeId,
target_x25519_pub: &[u8; 32],
granted_at_ms: u64,
current: bool,
) -> anyhow::Result<()> {
self.conn.execute(
"INSERT INTO own_vouch_targets
(voucher_persona_id, target_persona_id, target_x25519_pub, granted_at_ms, current)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(voucher_persona_id, target_persona_id) DO UPDATE SET
target_x25519_pub = excluded.target_x25519_pub,
current = excluded.current",
params![
voucher_persona_id.as_slice(),
target_persona_id.as_slice(),
target_x25519_pub.as_slice(),
granted_at_ms as i64,
current as i64,
],
)?;
Ok(())
}
/// List current outbound vouch targets for a persona.
pub fn list_current_vouch_targets(
&self,
voucher_persona_id: &NodeId,
) -> anyhow::Result<Vec<(NodeId, [u8; 32], u64)>> {
let mut stmt = self.conn.prepare(
"SELECT target_persona_id, target_x25519_pub, granted_at_ms
FROM own_vouch_targets
WHERE voucher_persona_id = ?1 AND current = 1
ORDER BY granted_at_ms ASC",
)?;
let rows = stmt.query_map(params![voucher_persona_id.as_slice()], |row| {
let tid: Vec<u8> = row.get(0)?;
let xpub: Vec<u8> = row.get(1)?;
let at: i64 = row.get(2)?;
Ok((tid, xpub, at))
})?;
let mut out = Vec::new();
for r in rows {
let (tid_bytes, xpub_bytes, at) = r?;
let tid: NodeId = tid_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid target_persona_id"))?;
let xpub: [u8; 32] = xpub_bytes.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid target_x25519_pub"))?;
out.push((tid, xpub, at as u64));
}
Ok(out)
}
/// Mark a vouch target as no longer current (soft revoke; row retained
/// so the audit trail / cascade-pickup is preserved).
pub fn revoke_vouch_target(
&self,
voucher_persona_id: &NodeId,
target_persona_id: &NodeId,
) -> anyhow::Result<()> {
self.conn.execute(
"UPDATE own_vouch_targets SET current = 0
WHERE voucher_persona_id = ?1 AND target_persona_id = ?2",
params![
voucher_persona_id.as_slice(),
target_persona_id.as_slice(),
],
)?;
Ok(())
}
// --- File holders (flat, per-file, LRU-capped at 5) ---
//
// A single table for PostId-keyed engagement propagation and CID-keyed