feat(fof-layer4): supersedes_post_id field on Post

Adds an optional supersedes_post_id pointer to Post for the
"re-issue with narrower access" path. Author publishes a new post
that references an earlier one; receivers can render "this is a
re-issued version of an earlier post" + offer to view the original.

Covered by PostId = BLAKE3(Post) — receivers can verify it wasn't
forged by anyone but the author.

#[serde(default)] for back-compat; existing posts deserialize with
None. All existing Post construction sites bulk-updated to set the
field to None.

Storage::get_post returns None for the field today; a follow-up can
add a dedicated column if/when receivers need to render the
pointer. The author-side re-issue helper that creates posts with
this field set comes in the Tauri/UI slice — the wire shape is in
place now.

148 tests pass (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:29:55 -06:00
parent c2f2203331
commit fdbf97f2d7
11 changed files with 50 additions and 0 deletions

View file

@ -144,6 +144,7 @@ pub fn build_announcement_post(
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
} }
} }

View file

@ -24,6 +24,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let id1 = compute_post_id(&post); let id1 = compute_post_id(&post);
let id2 = compute_post_id(&post); let id2 = compute_post_id(&post);
@ -38,6 +39,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let post2 = Post { let post2 = Post {
author: [1u8; 32], author: [1u8; 32],
@ -45,6 +47,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
assert_ne!(compute_post_id(&post1), compute_post_id(&post2)); assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
} }
@ -57,6 +60,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let id = compute_post_id(&post); let id = compute_post_id(&post);
assert!(verify_post_id(&id, &post)); assert!(verify_post_id(&id, &post));

View file

@ -156,6 +156,7 @@ pub fn build_delete_control_post(
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
} }
} }
@ -184,6 +185,7 @@ pub fn build_visibility_control_post(
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
} }
} }
@ -215,6 +217,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
@ -244,6 +247,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();

View file

@ -914,6 +914,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 3000, timestamp_ms: 3000,
fof_gating: Some(built.gating.clone()), fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
}; };
// Bob's device unlocks the post via his V_me (= v_x_bob). // Bob's device unlocks the post via his V_me (= v_x_bob).
@ -995,6 +996,7 @@ mod tests {
let post = crate::types::Post { let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![], author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
}; };
s.store_post_with_intent( s.store_post_with_intent(
&post_id, &post, &post_id, &post,
@ -1085,6 +1087,7 @@ mod tests {
let post = crate::types::Post { let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![], author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
}; };
s.store_post_with_intent( s.store_post_with_intent(
&post_id, &post, &post_id, &post,
@ -1186,6 +1189,7 @@ mod tests {
let post = crate::types::Post { let post = crate::types::Post {
author: alice_id, content: "x".into(), attachments: vec![], author: alice_id, content: "x".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
}; };
s.store_post_with_intent( s.store_post_with_intent(
&post_id, &post, &post_id, &post,
@ -1334,6 +1338,7 @@ mod tests {
let alice_post = crate::types::Post { let alice_post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![], author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
}; };
let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap() let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
.expect("Bob can unlock"); .expect("Bob can unlock");

View file

@ -62,6 +62,7 @@ pub fn build_distribution_post(
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let post_id = compute_post_id(&post); let post_id = compute_post_id(&post);
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
@ -243,6 +244,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 200, timestamp_ms: 200,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

View file

@ -290,6 +290,7 @@ pub async fn import_as_personas(
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
// Preserve the original visibility intent from the export. // Preserve the original visibility intent from the export.
@ -464,6 +465,7 @@ pub async fn import_public_posts(
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
// Read blob data from archive // Read blob data from archive
@ -701,6 +703,7 @@ pub async fn merge_with_key(
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
// Read blob data from archive (may need decryption for encrypted posts) // Read blob data from archive (may need decryption for encrypted posts)

View file

@ -2259,6 +2259,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
} }
} }

View file

@ -828,6 +828,7 @@ impl Node {
attachments: vec![], attachments: vec![],
timestamp_ms: pi.created_at, timestamp_ms: pi.created_at,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
{ {
@ -1168,6 +1169,7 @@ impl Node {
attachments: vec![], attachments: vec![],
timestamp_ms: now, timestamp_ms: now,
fof_gating: Some(built.gating), fof_gating: Some(built.gating),
supersedes_post_id: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
@ -1321,6 +1323,7 @@ impl Node {
attachments, attachments,
timestamp_ms: now, timestamp_ms: now,
fof_gating, fof_gating,
supersedes_post_id: None,
}; };
let post_id = compute_post_id(&post); let post_id = compute_post_id(&post);
@ -3345,6 +3348,7 @@ impl Node {
attachments: post.attachments.clone(), attachments: post.attachments.clone(),
timestamp_ms: post.timestamp_ms, timestamp_ms: post.timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let new_post_id = compute_post_id(&new_post); let new_post_id = compute_post_id(&new_post);

View file

@ -196,6 +196,7 @@ pub fn build_profile_post(
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
} }
} }
@ -465,6 +466,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
// Apply. Auto-scan should fire and store the unwrapped V_me. // Apply. Auto-scan should fire and store the unwrapped V_me.
@ -534,6 +536,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();

View file

@ -966,6 +966,15 @@ impl Storage {
attachments, attachments,
timestamp_ms: row.get::<_, i64>(3)? as u64, timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating, fof_gating,
// FoF Layer 4: supersedes_post_id is not persisted as a
// dedicated column today; it would arrive in the JSON
// form via fof_gating_json on re-issued posts. For now,
// get_post returns None and re-issue UX surfaces it
// when present in the in-memory Post (Layer 4 re-issue
// helper sets it inline). A follow-up can add a
// dedicated column if/when receivers need to render the
// supersedes pointer in feeds.
supersedes_post_id: None,
})) }))
} else { } else {
Ok(None) Ok(None)
@ -993,6 +1002,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: row.get::<_, i64>(3)? as u64, timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
))) )))
@ -1087,6 +1097,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
)); ));
@ -1126,6 +1137,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
)); ));
@ -1289,6 +1301,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
)); ));
@ -1325,6 +1338,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
)); ));
@ -3012,6 +3026,7 @@ impl Storage {
attachments, attachments,
timestamp_ms: row.get::<_, i64>(4)? as u64, timestamp_ms: row.get::<_, i64>(4)? as u64,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}, },
visibility, visibility,
)); ));
@ -6414,6 +6429,7 @@ mod tests {
attachments: vec![], attachments: vec![],
timestamp_ms: ts, timestamp_ms: ts,
fof_gating: None, fof_gating: None,
supersedes_post_id: None,
}; };
let id = blake3::hash(&serde_json::to_vec(&post).unwrap()); let id = blake3::hash(&serde_json::to_vec(&post).unwrap());
s.store_post(id.as_bytes(), &post).unwrap(); s.store_post(id.as_bytes(), &post).unwrap();

View file

@ -48,6 +48,13 @@ pub struct Post {
/// t=0, not the live mutable state. /// t=0, not the live mutable state.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub fof_gating: Option<FoFCommentGating>, pub fof_gating: Option<FoFCommentGating>,
/// FoF Layer 4: optional pointer to a post this one supersedes.
/// Used by the "re-issue with narrower access" path (advanced
/// rotation). Readers may display "this is a re-issued version
/// of an earlier post" + offer to view the original if still
/// cached. Covered by PostId since it's part of the signed Post.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_post_id: Option<PostId>,
} }
/// A reference to a media blob attached to a post /// A reference to a media blob attached to a post