Profile-post backfill + prune disposable first-run persona on import

Two bugs the v0.6.2 Discover story was going to expose:

1) Many named personas don't have a profile post yet, so the planned
   profile-post-driven Discover listing would show them as headless.
   Existing personas predate the Phase 2d profile-post primitive, and
   imported personas arrive with a display_name but no matching post.

2) Fresh install always generates a blank disposable persona before
   the user can pick fresh-vs-import. If the user picks import, that
   blank persona lingers forever — visible in the Personas list, a
   potential default, confusing.

Fixes:

Profile-post backfill (node.rs):
- New `Node::backfill_profile_posts_for_named_personas`: scans posting
  identities, skips unnamed or already-covered ones, and emits a
  signed profile post at the persona's own `created_at` timestamp. Run
  once from `Node::open_with_bind` after migrations. Idempotent via
  the new `Storage::has_profile_post_by_author` check. Chronology is
  preserved (post timestamp matches persona creation), so a later
  genuine profile update the user authors always wins the `> old_ts`
  monotonicity check on receivers.
- `Node::create_posting_identity(name)`: if `name` is non-empty, emits
  the profile post inline so new named personas are Discover-able the
  moment they're created. Uses the new `publish_profile_post_as`
  helper, which signs with the persona's own secret (not the default
  posting secret) and propagates via `update_neighbor_manifests_as`.

First-run persona marker + targeted prune (node.rs + storage.rs + import):
- `Node::open_with_bind`'s auto-gen block now also writes
  `first_run_auto_persona_id = <hex>` into the settings kv. This marks
  the specific disposable persona from the fresh-install flow; later
  prune logic uses this id, not a "any empty persona" rule, so manual
  empty personas are never touched.
- `Node::try_prune_first_run_auto_persona`: deletes the marked id iff
  all four gates pass — still exists, no display_name, no authored
  posts, no authored reactions/comments, and it's no longer the
  current default. Any one failure → clear the marker and keep the
  persona. New storage helpers `has_any_post_by_author`,
  `has_any_engagement_by_author` back the check.
- `set_profile` clears the marker when a non-empty name lands on the
  marked persona (user claimed it).
- `storage.delete_setting(key)` — new one-line helper.
- `import_as_personas_cmd` in tauri-app calls the prune after import
  completes; the cmd's return message reports "(cleared blank starter
  persona)" when it fires.
- New `get_first_run_auto_persona_id` Tauri command so the frontend
  can filter the blank persona out of the Personas list while it
  still exists.
- Frontend `loadPersonas` filters the marker id out of
  `personasCache` before rendering.

Tests: 124 / 124 core tests pass.
This commit is contained in:
Scott Reimers 2026-04-23 08:47:30 -04:00
parent d990da5bda
commit e74bd4e6c6
4 changed files with 308 additions and 6 deletions

View file

@ -133,6 +133,11 @@ impl Node {
created_at: now, created_at: now,
})?; })?;
s.set_default_posting_id(&nid)?; s.set_default_posting_id(&nid)?;
// Mark this as the disposable auto-gen persona from the
// fresh-install flow. If the user subsequently imports, we
// prune this id iff it's still pristine (no name, no posts,
// no engagement). See `try_prune_first_run_auto_persona`.
let _ = s.set_setting("first_run_auto_persona_id", &hex::encode(nid));
info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)"); info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)");
} }
} }
@ -223,6 +228,14 @@ impl Node {
budget_last_reset_ms, budget_last_reset_ms,
}; };
// Startup backfill: any named persona without a profile post gets
// one synthesized at its own `created_at`. Makes legacy / imported
// named personas Discover-able without requiring a manual rename.
// Swallow errors — backfill is best-effort; no reason to block init.
if let Err(e) = node.backfill_profile_posts_for_named_personas().await {
warn!(error = %e, "Profile-post backfill failed; continuing init");
}
Ok(node) Ok(node)
} }
@ -649,16 +662,213 @@ impl Node {
let identity = crate::types::PostingIdentity { let identity = crate::types::PostingIdentity {
node_id, node_id,
secret_seed: seed, secret_seed: seed,
display_name, display_name: display_name.clone(),
created_at: now, created_at: now,
}; };
let s = self.storage.get().await; {
s.upsert_posting_identity(&identity)?; let s = self.storage.get().await;
// Auto-follow this persona so its own posts reach its own feed. s.upsert_posting_identity(&identity)?;
s.add_follow(&node_id)?; // Auto-follow this persona so its own posts reach its own feed.
s.add_follow(&node_id)?;
}
// If the user supplied a non-empty display name at creation time,
// emit a signed profile post immediately. This makes the persona
// Discover-able by other nodes even before the user posts anything
// under it. `publish_profile_post_as` signs with the persona's own
// secret (not the default posting secret) and propagates via the
// normal neighbor-manifest CDN path.
if !display_name.is_empty() {
if let Err(e) = self.publish_profile_post_as(&node_id, &seed, &display_name, "", None).await {
warn!(persona = hex::encode(node_id), error = %e, "Failed to emit initial profile post for new persona");
}
}
Ok(identity) Ok(identity)
} }
/// Build + store + propagate a `VisibilityIntent::Profile` post authored
/// by the given persona (not the default posting identity). Extracted so
/// both `create_posting_identity` and the startup backfill can use it.
async fn publish_profile_post_as(
&self,
posting_id: &NodeId,
posting_secret: &[u8; 32],
display_name: &str,
bio: &str,
avatar_cid: Option<[u8; 32]>,
) -> anyhow::Result<()> {
let profile_post = crate::profile::build_profile_post(
posting_id,
posting_secret,
display_name,
bio,
avatar_cid,
);
let profile_post_id = crate::content::compute_post_id(&profile_post);
let timestamp_ms = profile_post.timestamp_ms;
{
let storage = self.storage.get().await;
storage.store_post_with_intent(
&profile_post_id,
&profile_post,
&PostVisibility::Public,
&VisibilityIntent::Profile,
)?;
crate::profile::apply_profile_post_if_applicable(
&*storage,
&profile_post,
Some(&VisibilityIntent::Profile),
)?;
}
self.update_neighbor_manifests_as(
posting_id,
posting_secret,
&profile_post_id,
timestamp_ms,
).await;
Ok(())
}
/// Backfill: for every posting identity with a non-empty display_name
/// that doesn't already have a `VisibilityIntent::Profile` post,
/// synthesize one so the persona becomes Discover-able. Uses the
/// persona's `created_at` as the post timestamp so chronology matches
/// the persona's history.
///
/// Called once from `Node::open_with_bind` after all migrations. Safe to
/// re-run: the `has_profile_post_by_author` check makes it idempotent.
async fn backfill_profile_posts_for_named_personas(&self) -> anyhow::Result<usize> {
let personas = {
let storage = self.storage.get().await;
storage.list_posting_identities()?
};
let mut backfilled = 0usize;
for pi in personas {
if pi.display_name.is_empty() {
continue;
}
{
let storage = self.storage.get().await;
if storage.has_profile_post_by_author(&pi.node_id)? {
continue;
}
}
// Build a profile post whose internal timestamp equals the
// persona's created_at. This stops the backfilled post from
// later losing a monotonicity check against a real profile
// update the user authors in the future.
let signature = crate::crypto::sign_profile(
&pi.secret_seed,
&pi.display_name,
"",
&None,
pi.created_at,
);
let content = crate::types::ProfilePostContent {
display_name: pi.display_name.clone(),
bio: String::new(),
avatar_cid: None,
timestamp_ms: pi.created_at,
signature,
};
let post = Post {
author: pi.node_id,
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms: pi.created_at,
};
let post_id = crate::content::compute_post_id(&post);
{
let storage = self.storage.get().await;
storage.store_post_with_intent(
&post_id,
&post,
&PostVisibility::Public,
&VisibilityIntent::Profile,
)?;
crate::profile::apply_profile_post_if_applicable(
&*storage,
&post,
Some(&VisibilityIntent::Profile),
)?;
}
self.update_neighbor_manifests_as(
&pi.node_id,
&pi.secret_seed,
&post_id,
pi.created_at,
).await;
backfilled += 1;
}
if backfilled > 0 {
info!(count = backfilled, "Backfilled profile posts for named personas without one");
}
Ok(backfilled)
}
/// If the fresh-install auto-gen persona is still pristine (no name, no
/// posts, no engagement, not the current default), delete it. Called at
/// the end of `import_as_personas` so an "import as persona" flow
/// doesn't leave an orphan blank persona around.
///
/// Any of four sticky conditions prevents deletion:
/// - the user set a display_name
/// - the user authored a post under this persona
/// - the user authored a reaction or comment under this persona
/// - this persona is still the current default (no imported identity
/// replaced it)
pub async fn try_prune_first_run_auto_persona(&self) -> anyhow::Result<bool> {
let (marker_hex, current_default) = {
let s = self.storage.get().await;
let m = s.get_setting("first_run_auto_persona_id")?;
let d = s.get_default_posting_id()?;
(m, d)
};
let Some(hex_str) = marker_hex else { return Ok(false); };
let Ok(marker_id) = crate::parse_node_id_hex(&hex_str) else {
// Corrupt marker — clear and move on.
let s = self.storage.get().await;
let _ = s.delete_setting("first_run_auto_persona_id");
return Ok(false);
};
let storage = self.storage.get().await;
// Still the default? Import didn't replace it — keep.
if current_default == Some(marker_id) {
let _ = storage.delete_setting("first_run_auto_persona_id");
return Ok(false);
}
// Persona still exists?
let Some(pi) = storage.get_posting_identity(&marker_id)? else {
let _ = storage.delete_setting("first_run_auto_persona_id");
return Ok(false);
};
// User named it? Keep.
if !pi.display_name.is_empty() {
let _ = storage.delete_setting("first_run_auto_persona_id");
return Ok(false);
}
// User authored anything under it? Keep.
if storage.has_any_post_by_author(&marker_id)? {
let _ = storage.delete_setting("first_run_auto_persona_id");
return Ok(false);
}
if storage.has_any_engagement_by_author(&marker_id)? {
let _ = storage.delete_setting("first_run_auto_persona_id");
return Ok(false);
}
// All gates passed — persona is definitively pristine and no longer
// the default. Safe to drop.
storage.delete_posting_identity(&marker_id)?;
let _ = storage.remove_follow(&marker_id);
let _ = storage.delete_setting("first_run_auto_persona_id");
info!(persona = %hex_str, "Pruned pristine fresh-install persona after import");
Ok(true)
}
/// Delete a posting identity. Refuses to delete the currently default /// Delete a posting identity. Refuses to delete the currently default
/// posting identity unless the caller has already switched the default. /// posting identity unless the caller has already switched the default.
pub async fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> { pub async fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> {
@ -1263,6 +1473,8 @@ impl Node {
let timestamp_ms = profile_post.timestamp_ms; let timestamp_ms = profile_post.timestamp_ms;
// Store post with VisibilityIntent::Profile + apply (upserts profile row). // Store post with VisibilityIntent::Profile + apply (upserts profile row).
// If naming the fresh-install auto-gen persona with a non-empty name,
// clear the disposability marker — user has claimed this persona.
{ {
let storage = self.storage.get().await; let storage = self.storage.get().await;
storage.store_post_with_intent( storage.store_post_with_intent(
@ -1276,6 +1488,13 @@ impl Node {
&profile_post, &profile_post,
Some(&VisibilityIntent::Profile), Some(&VisibilityIntent::Profile),
)?; )?;
if !display_name.is_empty() {
if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") {
if marker == hex::encode(posting_id) {
let _ = storage.delete_setting("first_run_auto_persona_id");
}
}
}
} }
// Propagate via neighbor-manifest header diffs like any other post. // Propagate via neighbor-manifest header diffs like any other post.

View file

@ -1677,6 +1677,11 @@ impl Storage {
Ok(()) Ok(())
} }
pub fn delete_setting(&self, key: &str) -> anyhow::Result<()> {
self.conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
}
// --- Seen engagement tracking --- // --- Seen engagement tracking ---
/// Get the seen engagement counts for a post (react_count, comment_count). /// Get the seen engagement counts for a post (react_count, comment_count).
@ -2662,6 +2667,49 @@ impl Storage {
} }
/// Find posts authored by us that were intended for a specific circle. /// Find posts authored by us that were intended for a specific circle.
/// True if the given author has at least one `VisibilityIntent::Profile`
/// post stored. Used by the startup backfill to avoid re-emitting a
/// profile post for personas that already have one.
pub fn has_profile_post_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM posts
WHERE author = ?1 AND visibility_intent = '\"Profile\"'
LIMIT 1",
params![author.as_slice()],
|row| row.get(0),
).unwrap_or(0);
Ok(count > 0)
}
/// True if `author` has any row in the posts table (any intent / visibility).
/// Used to decide whether the fresh-install auto-gen persona has been
/// "used" and should therefore NOT be pruned on import.
pub fn has_any_post_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM posts WHERE author = ?1 LIMIT 1",
params![author.as_slice()],
|row| row.get(0),
).unwrap_or(0);
Ok(count > 0)
}
/// True if `author` has authored a reaction or a comment. Same purpose as
/// `has_any_post_by_author` but for the engagement tables.
pub fn has_any_engagement_by_author(&self, author: &NodeId) -> anyhow::Result<bool> {
let reacts: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM reactions WHERE reactor = ?1 LIMIT 1",
params![author.as_slice()],
|row| row.get(0),
).unwrap_or(0);
if reacts > 0 { return Ok(true); }
let comments: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM comments WHERE author = ?1 LIMIT 1",
params![author.as_slice()],
|row| row.get(0),
).unwrap_or(0);
Ok(comments > 0)
}
pub fn find_posts_by_circle_intent( pub fn find_posts_by_circle_intent(
&self, &self,
circle_name: &str, circle_name: &str,

View file

@ -1416,6 +1416,20 @@ async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String>
.unwrap_or_else(|| "stable".to_string())) .unwrap_or_else(|| "stable".to_string()))
} }
/// Return the hex id of the disposable fresh-install persona (if it still
/// exists and hasn't been claimed). Frontend uses this to filter the blank
/// starter out of the Personas list so the user never sees a "ghost"
/// persona waiting between install and their first-run choice.
/// Returns an empty string when there's no disposable persona to hide.
#[tauri::command]
async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
Ok(storage.get_setting("first_run_auto_persona_id")
.map_err(|e| e.to_string())?
.unwrap_or_default())
}
/// Persist the user's preferred update channel. /// Persist the user's preferred update channel.
#[tauri::command] #[tauri::command]
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> { async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
@ -2804,7 +2818,17 @@ async fn import_as_personas_cmd(
&node.storage, &node.storage,
&node.blob_store, &node.blob_store,
).await.map_err(|e| e.to_string())?; ).await.map_err(|e| e.to_string())?;
Ok(result.message) // Drop the disposable fresh-install persona if it's still pristine.
// Safe-by-construction: the helper bails unless ALL of [no name, no
// posts, no engagement, no longer the default] hold.
let pruned = node.try_prune_first_run_auto_persona().await
.unwrap_or(false);
let msg = if pruned {
format!("{} (cleared blank starter persona)", result.message)
} else {
result.message
};
Ok(msg)
} }
#[tauri::command] #[tauri::command]
@ -3023,6 +3047,7 @@ pub fn run() {
get_update_channel, get_update_channel,
set_update_channel, set_update_channel,
open_url_external, open_url_external,
get_first_run_auto_persona_id,
list_connections, list_connections,
worm_lookup, worm_lookup,
list_social_routes, list_social_routes,

View file

@ -3521,6 +3521,16 @@ async function loadPersonas() {
personasCache = []; personasCache = [];
console.error('list_posting_identities:', e); console.error('list_posting_identities:', e);
} }
// Filter out the fresh-install disposable persona. It's auto-created
// before the user has picked fresh-vs-import, and will be pruned on
// import if still pristine. Hiding it from the Personas UI stops the
// user from seeing a ghost "blank" persona during the first-run flow.
try {
const hiddenId = await invoke('get_first_run_auto_persona_id');
if (hiddenId) {
personasCache = personasCache.filter(p => p.nodeId !== hiddenId);
}
} catch (_) {}
renderPersonasList(); renderPersonasList();
renderComposePersonaPicker(); renderComposePersonaPicker();
renderFeedPersonaFilter(); renderFeedPersonaFilter();