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:
parent
fa02ace4cc
commit
8b042f6598
20 changed files with 1676 additions and 255 deletions
|
|
@ -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 30–365d 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(®istry_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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue