Phase 2b: control-post flow (delete/visibility) + remove BlobDeleteNotice

Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.

Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
  by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
  for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
  sites. Verifies control signatures BEFORE storing, so bogus controls never
  enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
  same storage guard as the insert.

Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
  `list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
  they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
  query so control posts still propagate via CDN.

Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
  arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
  naturally via LRU rather than via a persona-signed push. Code path,
  payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.

Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
This commit is contained in:
Scott Reimers 2026-04-22 21:17:34 -04:00
parent 4da6a8dc85
commit 36b6a466d2
10 changed files with 585 additions and 196 deletions

View file

@ -1074,6 +1074,9 @@ impl Node {
VisibilityIntent::Friends => storage.list_public_follows(),
VisibilityIntent::Circle(name) => storage.get_circle_members(name),
VisibilityIntent::Direct(ids) => Ok(ids.clone()),
// Control and Profile posts are always Public on the wire; they
// never go through encryption recipient resolution.
VisibilityIntent::Control | VisibilityIntent::Profile => Ok(vec![]),
}
}
@ -2155,39 +2158,32 @@ impl Node {
// ---- Delete / Revocation ----
pub async fn delete_post(&self, post_id: &PostId) -> anyhow::Result<()> {
let post = {
// Load the target post and the posting identity of its author. Only
// the author can delete their own content, so the signing key must be
// one we hold in posting_identities.
let (target_author, author_secret) = {
let storage = self.storage.get().await;
storage
let post = storage
.get_post(post_id)?
.ok_or_else(|| anyhow::anyhow!("post not found"))?
};
if post.author != self.node_id {
anyhow::bail!("cannot delete: you are not the author");
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let signature = crypto::sign_delete(&self.default_posting_secret, post_id);
let record = DeleteRecord {
post_id: *post_id,
author: self.default_posting_id,
timestamp_ms: now,
signature,
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
let pi = storage
.get_posting_identity(&post.author)?
.ok_or_else(|| anyhow::anyhow!("cannot delete: not authored by a persona on this device"))?;
(pi.node_id, pi.secret_seed)
};
// Collect blob CIDs + known holders before cleanup (for delete notices)
let blob_cdn_info: Vec<([u8; 32], Vec<(NodeId, Vec<String>)>, Option<(NodeId, Vec<String>)>)> = {
let storage = self.storage.get().await;
let cids = storage.get_blobs_for_post(post_id).unwrap_or_default();
cids.into_iter().map(|cid| {
let holders = storage.get_file_holders(&cid).unwrap_or_default();
(cid, holders, None::<(NodeId, Vec<String>)>)
}).collect()
};
// Build the control-delete post signed by the target's author.
let control_post = crate::control::build_delete_control_post(
&target_author,
&author_secret,
post_id,
);
let control_post_id = crate::content::compute_post_id(&control_post);
let now = control_post.timestamp_ms;
// Clean up blobs (DB metadata + CDN metadata + filesystem)
// Clean up blob storage local-side. Blobs in remote holders become
// orphans and get evicted naturally via LRU — BlobDeleteNotice is
// gone in v0.6.2.
let blob_cids = {
let storage = self.storage.get().await;
let cids = storage.delete_blobs_for_post(post_id)?;
@ -2202,19 +2198,42 @@ impl Node {
}
}
// Store the control post locally with VisibilityIntent::Control so
// feeds filter it and propagation queries find it. Apply the op under
// the same guard so delete recording + target cleanup happen with the
// control-post insert atomically.
{
let storage = self.storage.get().await;
storage.store_delete(&record)?;
storage.apply_delete(&record)?;
storage.store_post_with_intent(
&control_post_id,
&control_post,
&PostVisibility::Public,
&VisibilityIntent::Control,
)?;
crate::control::apply_control_post_if_applicable(
&*storage,
&control_post,
Some(&VisibilityIntent::Control),
)?;
}
// Send CDN delete notices for each blob
for (cid, downstream, upstream) in &blob_cdn_info {
self.network.send_blob_delete_notices(cid, downstream, upstream.as_ref()).await;
}
// Propagate via the normal neighbor-manifest CDN path: include the
// control post in the author's other posts' `following_posts` lists
// and push manifest diffs to their file_holders. Peers who follow
// any of the author's posts pick up the control post and apply it.
self.update_neighbor_manifests_as(
&target_author,
&author_secret,
&control_post_id,
now,
).await;
let pushed = self.network.push_delete(&record).await;
info!(post_id = hex::encode(post_id), pushed, blobs_removed = blob_cids.len(), "Deleted post");
info!(
post_id = hex::encode(post_id),
control_post_id = hex::encode(control_post_id),
blobs_removed = blob_cids.len(),
"Deleted post via control post",
);
Ok(())
}
@ -2269,13 +2288,38 @@ impl Node {
storage.update_post_visibility(post_id, &new_vis)?;
}
let update = VisibilityUpdate {
post_id: *post_id,
author: self.default_posting_id,
visibility: new_vis,
// Propagate via a signed control-visibility post rather than a
// direct push. Only the target's author can make such a post.
let author_secret = {
let s = self.storage.get().await;
s.get_posting_identity(&post.author)?
.map(|pi| pi.secret_seed)
.ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))?
};
let pushed = self.network.push_visibility(&update).await;
info!(post_id = hex::encode(post_id), pushed, "Revoked access (sync mode)");
let control_post = crate::control::build_visibility_control_post(
&post.author,
&author_secret,
post_id,
&new_vis,
);
let control_post_id = crate::content::compute_post_id(&control_post);
let now = control_post.timestamp_ms;
{
let storage = self.storage.get().await;
storage.store_post_with_intent(
&control_post_id,
&control_post,
&PostVisibility::Public,
&VisibilityIntent::Control,
)?;
}
self.update_neighbor_manifests_as(
&post.author,
&author_secret,
&control_post_id,
now,
).await;
info!(post_id = hex::encode(post_id), control_post_id = hex::encode(control_post_id), "Revoked access (sync mode) via control post");
Ok(None)
}
RevocationMode::ReEncrypt => {
@ -3477,25 +3521,14 @@ impl Node {
compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, now_ms)
}
/// Delete a blob with CDN notifications to known holders.
pub async fn delete_blob_with_cdn_notify(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
// Gather known holders before cleanup
let holders = {
let storage = self.storage.get().await;
storage.get_file_holders(cid).unwrap_or_default()
};
// Send CDN delete notices to all holders
self.network.send_blob_delete_notices(cid, &holders, None).await;
// Clean up local storage
/// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote
/// holders notice eviction via their own LRU / replica-miss handling.
pub async fn delete_blob_local(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
{
let storage = self.storage.get().await;
storage.cleanup_cdn_for_blob(cid)?;
storage.remove_blob(cid)?;
}
// Delete from filesystem
let _ = self.blob_store.delete(cid);
Ok(())
@ -3542,7 +3575,7 @@ impl Node {
if bytes_freed >= target_free {
break;
}
if let Err(e) = self.delete_blob_with_cdn_notify(&candidate.cid).await {
if let Err(e) = self.delete_blob_local(&candidate.cid).await {
warn!(cid = hex::encode(candidate.cid), error = %e, "Failed to evict blob");
continue;
}