fix: comment retention taxonomy — TTL by (post policy x signing class)

Retention was keyed on comment type, so public and post-key-signed comments
carried a randomized 30-365d expiry inside their signed digest. Under the
ruling neither should ever expire; only the private, non-post-key-signed
(open-slot/greeting) channel gets an automatic TTL, since that is the
throwaway-identity retirement mechanism.

New crates/core/src/comment_ttl.rs is the single authority: CommentClass
(Public / PostKeySigned / OpenSlot / Unverifiable) x CommentTtlRule
(Never | Window | UnknownParent), with draw_expiry (writer) and ttl_ok
(holder). expires_at_ms == 0 is the never-expires sentinel, honored by the
sweep, the ingest gate, and the store_comment upsert.

- Post.comment_ttl: Option<CommentTtlPolicy> — a GENERIC per-post policy, so
  an author-set TTL is a future config surface, not a redesign. Registry posts
  declare a flat 30d policy that binds EVERY comment on them (registrations,
  duplicate reports, anything else), replacing the registration-only rule.
- OpenSlotDecl.max_comments: author-declarable cap on private PK-unsigned
  comments, enforced holder-side (clamped to the holder default), replacing
  the hardcoded per-bio greeting cap. Refusal remains "declare no slot".
  Node::set_greetings_max + `greetings-max` CLI command to write it.
- Holder enforcement rejects TTLs contradicting the parent's policy in both
  directions; a comment naming a different post than its envelope is rejected.
- UnknownParent rule: bounded TTLs accepted from unheld parents (self-heal),
  never-expires refused — permanence is not granted on unseen evidence.

Also fixed while here: five Post-reconstructing queries silently dropped
comment_ttl AND the pre-existing fof_gating (shipped in v0.8.0-alpha), so any
gated or policy-carrying post failed BLAKE3 verification on sync/export and
was discarded with no diagnostic. All hydration now goes through one
POST_COLUMNS/post_from_row path; export/import round-trips the policy.

Registry frozen bytes regenerated for the policy field; REGISTRY_POST_ID is
now 10a1be3383efb2977607fe45c4a7b3f1b5e626e81d0ac1af9c0f3d7eb9864d32.
design.html section 21 rewritten to the corrected taxonomy.

250 core tests (was 228); a3 integration 12/12 (new step 6 asserts registry
comments hold exactly 30d while greetings randomize); c_topology 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-08-03 15:33:44 -04:00
parent fa02ace4cc
commit 8b042f6598
20 changed files with 1676 additions and 255 deletions

View file

@ -124,6 +124,36 @@ fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId
.unwrap_or(true)
}
/// v0.8: per-persona LIMIT on how many live stranger greetings the bio's
/// open slot will accept. Unset = the holder default
/// (`MAX_GREETINGS_PER_BIO`). The value is baked into the bio post's
/// `OpenSlotDecl` at publish time, so every holder enforces it — the
/// author cannot change it after the fact without republishing the bio.
/// No UI yet; the mechanism and plumbing are here for it.
pub fn greetings_max_setting_key(posting_id: &NodeId) -> String {
format!("greetings_max.{}", hex::encode(posting_id))
}
fn greetings_max_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> Option<u32> {
storage
.get_setting(&greetings_max_setting_key(posting_id))
.ok()
.flatten()
.and_then(|v| v.parse::<u32>().ok())
// 0 is not "refuse" — refusal is `greetings_open = 0`, which
// publishes a bio with no open slot at all.
.filter(|n| *n > 0)
}
/// Build the bio's Greeting open-slot spec from the persona's settings.
fn greeting_open_slot_spec(
storage: &crate::storage::Storage,
posting_id: &NodeId,
) -> crate::fof::OpenSlotSpec {
crate::fof::OpenSlotSpec::new(crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)
.with_limit(greetings_max_setting(storage, posting_id))
}
/// v0.8 (A3): persist the author-side state of a freshly-published
/// gated post: slot provenance (cascade revocation), cached CEK
/// (author-direct decrypt), and the FriendsOfFriends comment policy.
@ -1120,7 +1150,7 @@ impl Node {
crate::fof::build_fof_comment_gating(
&*storage,
posting_id,
Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)),
Some(greeting_open_slot_spec(&storage, posting_id)),
)?
} else {
None
@ -1217,6 +1247,7 @@ impl Node {
timestamp_ms: pi.created_at,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
{
@ -1574,6 +1605,7 @@ impl Node {
timestamp_ms: now,
fof_gating: Some(built.gating),
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
@ -1734,6 +1766,7 @@ impl Node {
timestamp_ms: now,
fof_gating,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = compute_post_id(&post);
@ -2143,7 +2176,7 @@ impl Node {
crate::fof::build_fof_comment_gating(
&*storage,
&posting_id,
Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)),
Some(greeting_open_slot_spec(&storage, &posting_id)),
)?
} else {
None
@ -3854,6 +3887,7 @@ impl Node {
timestamp_ms: post.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let new_post_id = compute_post_id(&new_post);
@ -5275,8 +5309,22 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
// v0.8 (A3): TTL drawn BEFORE signing — it's inside the digest.
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
// v0.8: TTL drawn BEFORE signing — it's inside the digest.
// Ordinary public comment: NO expiry unless the parent post
// declares a retention policy (registry posts do; a future
// author-set TTL would land in the same field).
let expires_at_ms = {
let storage = self.storage.get().await;
let parent = storage.get_post(&post_id).ok().flatten();
drop(storage);
crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
parent.as_ref(),
crate::comment_ttl::CommentClass::Public,
),
now,
)
};
let signature = crate::crypto::sign_comment(
&seed,
&our_node_id,
@ -5544,7 +5592,7 @@ impl Node {
post_id: PostId,
body: String,
) -> anyhow::Result<crate::types::InlineComment> {
let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author) = {
let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author, ttl_rule) = {
let storage = self.storage.get().await;
let post = storage.get_post(&post_id)?
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
@ -5555,7 +5603,13 @@ impl Node {
.ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?;
let identity = storage.get_posting_identity(&unlock.persona_id)?
.ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?;
(unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author)
// Post-key-signed (member) comment: NO expiry unless the
// post itself declares a retention policy.
let rule = crate::comment_ttl::rule_for(
Some(&post),
crate::comment_ttl::CommentClass::PostKeySigned,
);
(unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author, rule)
};
let now = std::time::SystemTime::now()
@ -5563,7 +5617,7 @@ impl Node {
.as_millis() as u64;
// v0.8 (A3): expiry rides the outer plaintext fields so
// non-member holders can expire the comment too.
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
let expires_at_ms = crate::comment_ttl::draw_expiry(ttl_rule, now);
let comment = crate::fof::build_fof_comment(
&post_id, &unlock, &slot_binder_nonce,
&commenter_id, &commenter_secret, &body, None, now, expires_at_ms,
@ -6295,6 +6349,52 @@ impl Node {
Ok(greetings_open_setting(&s, posting_id))
}
/// Ruling #6: the author-declarable LIMIT on live stranger greetings
/// for this persona's bio. `None` (or 0) = the holder default
/// ([`crate::connection::MAX_GREETINGS_PER_BIO`]); refusal is the
/// separate, structural case ([`Self::set_greetings_open`] with
/// `false`, which publishes a bio with no open slot at all).
///
/// The limit is baked into the bio post's signed `OpenSlotDecl`, so
/// — exactly like the consent flag — it only reaches holders when
/// the bio is republished. Writing the setting alone (e.g. through
/// the generic `set_setting` command) would have no observable
/// effect until some unrelated republish happened to pick it up.
pub async fn set_greetings_max(
&self,
posting_id: &NodeId,
max_comments: Option<u32>,
) -> anyhow::Result<()> {
let (secret, display_name, bio, avatar) = {
let s = self.storage.get().await;
let key = greetings_max_setting_key(posting_id);
match max_comments.filter(|n| *n > 0) {
Some(n) => s.set_setting(&key, &n.to_string())?,
None => s.set_setting(&key, "")?,
}
let identity = s
.get_posting_identity(posting_id)?
.ok_or_else(|| anyhow::anyhow!("persona not on this device"))?;
let profile = s.get_profile(posting_id)?;
(
identity.secret_seed,
profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(),
profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(),
profile.as_ref().and_then(|p| p.avatar_cid),
)
};
// Republish so the new `OpenSlotDecl.max_comments` enters the
// signed post every holder enforces.
self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?;
Ok(())
}
/// Current author-declared greeting limit (`None` = holder default).
pub async fn get_greetings_max(&self, posting_id: &NodeId) -> anyhow::Result<Option<u32>> {
let s = self.storage.get().await;
Ok(greetings_max_setting(&s, posting_id))
}
/// Best-effort network fetch of a post we don't hold: content-search
/// worm by post id, then PostFetch from the reported holders, stored
/// through the standard receive path.
@ -6426,7 +6526,16 @@ impl Node {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now);
// Open-slot stranger channel: the ONE class with an automatic
// TTL — randomized 30365d throwaway-ID retirement, unless the
// post author declared their own retention policy.
let expires_at_ms = crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
Some(&target_post),
crate::comment_ttl::CommentClass::OpenSlot,
),
now,
);
let comment = crate::fof::build_fof_comment(
&target_post_id,
&unlock,
@ -6640,7 +6749,17 @@ impl Node {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
let expires_at_ms = now + crate::registry::REGISTRATION_TTL_MS;
// The 30-day registration TTL now comes FROM THE POST's own
// retention policy (flat 30d), not from a registration-specific
// constant at the call site — same path a future author-set TTL
// would take.
let expires_at_ms = crate::comment_ttl::draw_expiry(
crate::comment_ttl::rule_for(
Some(&registry_post),
crate::comment_ttl::CommentClass::OpenSlot,
),
now,
);
// group_sig over (content_bytes || post_id || pub_x_index_le) —
// the plaintext-open-slot form of the standard scheme.
@ -6793,6 +6912,21 @@ impl Node {
let due: Vec<(NodeId, String, Vec<String>)> = {
let s = self.storage.get().await;
// What retention does the registry post itself impose on its
// comments? Under a Window rule (the flat 30d) a stored
// expiry of 0 is not a legal state — `get_newest_registry_entry`
// maps a NULL `expires_at` column to 0, so it cannot tell
// "never expires" from "no expiry recorded". Treating such a
// row as never-renew would silently drop the persona off the
// registry: remote holders still enforce the 30 days and
// delete the entry, while our local DB shows it as listed
// forever. Only a `Never` rule makes 0 mean never.
let registry_rule = crate::comment_ttl::rule_for(
s.get_post(&crate::registry::REGISTRY_POST_ID)?.as_ref(),
crate::comment_ttl::CommentClass::OpenSlot,
);
let zero_expiry_is_permanent =
matches!(registry_rule, crate::comment_ttl::CommentTtlRule::Never);
let mut due = Vec::new();
for persona in s.list_posting_identities()? {
let id_hex = hex::encode(persona.node_id);
@ -6808,6 +6942,11 @@ impl Node {
&persona.node_id,
)?;
let needs_renew = match newest {
// `exp == 0`: never-expires sentinel ONLY when the
// registry post declares no TTL policy (a future
// shard could). Under a Window rule it means the row
// has no expiry recorded — renew it.
Some((_ts, 0)) => !zero_expiry_is_permanent,
Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS,
None => true,
};