Compare commits

..

2 commits

Author SHA1 Message Date
Scott Reimers
3232796d4d docs: v0.8.0-alpha release notes — comment retention + gated-post sync
Re-stamped 0.8.0-alpha build carries the comment retention taxonomy fix
(only the anonymous/unsigned channel auto-expires; public and post-key-signed
comments are permanent; registry posts declare a flat 30-day policy) plus the
gated-post sync fix (posts rebuilt without their gating failed content-hash
verification and were silently discarded — this affected every bio carrying a
greeting slot). Upgrade note flags the unchanged Android version code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-08-06 22:54:29 -04:00
Scott Reimers
8b042f6598 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
2026-08-03 15:33:44 -04:00
21 changed files with 1684 additions and 256 deletions

View file

@ -245,6 +245,7 @@ async fn main() -> anyhow::Result<()> {
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
println!(" greetings-open <on|off> Toggle the greeting slot on your bio (republishes bio)");
println!(" greetings-max <n|default> Limit live stranger greetings on your bio (republishes bio)");
println!(" stats Show node stats");
println!(" export-key Export identity key (KEEP SECRET)");
println!(" id Show this node's ID");
@ -1164,6 +1165,44 @@ async fn main() -> anyhow::Result<()> {
}
}
// v0.8 ruling #6: author-declarable LIMIT on live stranger
// greetings. Baked into the bio's signed OpenSlotDecl, so
// setting it republishes the bio (holders clamp it to their
// own ceiling; authors may lower it, never raise it).
"greetings-max" => {
let pid = node.default_posting_id;
match arg.map(|a| a.trim()).filter(|a| !a.is_empty()) {
Some(a) => {
let limit = if a == "default" || a == "off" {
None
} else {
match a.parse::<u32>() {
Ok(n) => Some(n),
Err(_) => {
println!("Usage: greetings-max <n|default>");
continue;
}
}
};
match node.set_greetings_max(&pid, limit).await {
Ok(()) => match limit {
Some(n) => println!("Greeting limit set to {} (bio republished).", n),
None => println!("Greeting limit cleared — holder default applies (bio republished)."),
},
Err(e) => println!("Error: {}", e),
}
}
None => match node.get_greetings_max(&pid).await {
Ok(Some(n)) => println!("Greeting limit: {}. Usage: greetings-max <n|default>", n),
Ok(None) => println!(
"Greeting limit: holder default ({}). Usage: greetings-max <n|default>",
itsgoin_core::connection::MAX_GREETINGS_PER_BIO,
),
Err(e) => println!("Error: {}", e),
},
}
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
break;

View file

@ -145,6 +145,7 @@ pub fn build_announcement_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}

View file

@ -0,0 +1,480 @@
//! v0.8: comment retention — the single place that answers "how long may
//! THIS comment live on THAT post?".
//!
//! Ruling (2026-08-03): retention is decided by
//! **(parent post's comment-TTL policy) × (comment's signing class)**:
//!
//! | parent policy | signing class | rule |
//! |---|---|---|
//! | `Some(p)` | any | window `p` (registry post: flat 30d) |
//! | `None` | public (no slot) | **never expires** |
//! | `None` | post-key-signed (FoF/member) | **never expires** |
//! | `None` | open slot (stranger/greeting) | randomized 30365d |
//! | parent NOT HELD | any | [`CommentTtlRule::UnknownParent`] |
//!
//! The author policy is a plain field on the `Post`
//! ([`crate::types::CommentTtlPolicy`]) — the registry post gets its flat
//! 30 days through exactly the mechanism a future author-set TTL would
//! use, NOT through an `if is_registry_post` branch. To ship author-set
//! TTLs later: populate `Post.comment_ttl` in the composer path; every
//! writer and every holder-side check below already honours it.
//!
//! ## The `0` sentinel
//! `InlineComment.expires_at_ms == 0` means **never expires**. It is
//! inside the signed comment digest (crypto.rs digest v2), so the author
//! commits to it and holders can enforce it. Storage already maps `0` to
//! `NULL` and every read filter treats `NULL`/`0` as "not expired", so
//! the sweep ([`crate::storage::Storage::expire_comments`]) leaves them
//! alone.
//!
//! ## Why holders enforce, not just writers
//! A comment's TTL is self-asserted. Without holder-side validation a
//! spammer submits a no-expiry registration (permanent registry slot) or
//! a 10-year greeting (permanent squat on someone's 64-greeting budget).
//! [`ttl_ok`] is therefore run on EVERY ingest path, alongside the
//! signature check.
//!
//! ## Unheld parents: permanence is never granted on unseen evidence
//! A holder that does NOT hold the parent post has never seen its
//! retention policy. Collapsing that case to the class default would let
//! it assert "may never expire" about a post whose policy it cannot
//! read — and a `0`-expiry comment is permanent storage, the one
//! property that must never be granted on unverifiable evidence (a
//! second registry shard, or any future author-set TTL, would otherwise
//! be bypassable by offering the comment to non-holders).
//! [`CommentTtlRule::UnknownParent`] therefore accepts only a BOUNDED
//! TTL (≤ [`MAX_COMMENT_TTL_MS`]) and refuses the `0` sentinel: such a
//! comment self-heals — it is re-offered once the post is held (the
//! header-pull path re-offers, and holders re-derive their own headers).
//! Writer-side the same rule draws `0` ([`draw_expiry`]): the author of
//! an ordinary comment on a post they do not hold assumes "no policy",
//! which is the class default their own holders will confirm.
use crate::types::{CommentTtlPolicy, InlineComment, OpenSlotKind, Post};
/// Default randomized window for the open-slot stranger channel:
/// `rand(30..=365 days)`. The randomness is identity hygiene — throwaway
/// commenter IDs must not all retire on a predictable schedule.
pub const OPEN_SLOT_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const OPEN_SLOT_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// Absolute holder-side ceiling on any accepted TTL. A post policy that
/// asks for more is clamped to this; a comment claiming more is dropped.
pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000;
/// Clock-skew slack when checking a claimed TTL against a policy window.
pub const TTL_SLACK_MS: u64 = 5 * 60 * 1000;
/// What a comment's signature evidences about its author's standing on
/// the parent post. Computable by any holder from the parent post alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentClass {
/// No `pub_x_index`: an ordinary public comment.
Public,
/// Signed under a post-key slot that is NOT the open slot — evidence
/// of authorized/FoF membership.
PostKeySigned,
/// Signed under the post's OPEN slot, whose key anyone can derive.
/// Carries no authorization evidence: the greeting/registry stranger
/// channel. This is the only class with an automatic TTL.
OpenSlot,
/// Claims a slot (`pub_x_index`) on a post we do NOT hold, so the
/// claim cannot be checked against any `pub_post_set` — we cannot
/// tell an open-slot greeting from a member comment. Retention-wise
/// it resolves to [`CommentTtlRule::UnknownParent`] like anything
/// else on an unheld parent; the actual rejection belongs to the
/// ingest gate's slot-verification step, which attributes the drop
/// to the unverifiable slot claim rather than to a TTL mismatch.
Unverifiable,
}
/// The retention rule that applies to one comment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentTtlRule {
/// `expires_at_ms` MUST be the `0` sentinel.
Never,
/// `expires_at_ms - timestamp_ms` must land in the inclusive window
/// (±[`TTL_SLACK_MS`]). `min == max` is a flat TTL.
Window { min_ttl_ms: u64, max_ttl_ms: u64 },
/// We do not hold the parent post, so its retention policy is
/// unseen. Bounded TTLs (≤ [`MAX_COMMENT_TTL_MS`]) are accepted —
/// they self-heal — but the `0` never-expires sentinel is REFUSED:
/// permanence is never granted on evidence we cannot read. See the
/// module header.
UnknownParent,
}
/// Classify a comment against its parent post. With no `pub_x_index` the
/// comment is [`CommentClass::Public`] whether or not we hold the parent.
/// A slot claim on an UNHELD parent is [`CommentClass::Unverifiable`] —
/// not `PostKeySigned` — because nothing was verified: the ingest gate
/// drops it at slot verification, and attributing it correctly here
/// keeps that drop legible.
pub fn classify(parent: Option<&Post>, comment: &InlineComment) -> CommentClass {
let Some(idx) = comment.pub_x_index else {
return CommentClass::Public;
};
let Some(parent) = parent else {
return CommentClass::Unverifiable;
};
let open_idx = parent
.fof_gating
.as_ref()
.and_then(|g| g.open_slot.as_ref())
.map(|d| d.slot_index);
if open_idx == Some(idx) {
CommentClass::OpenSlot
} else {
CommentClass::PostKeySigned
}
}
/// Resolve the retention rule for a (parent post, signing class) pair.
/// An author policy wins over the class default for EVERY class — that
/// is what makes "all comments on a registry post expire at 30d" true of
/// duplicate-reports and any other comment, not just registrations.
pub fn rule_for(parent: Option<&Post>, class: CommentClass) -> CommentTtlRule {
let Some(parent) = parent else {
// We have never seen this post's policy — see the module header.
return CommentTtlRule::UnknownParent;
};
if let Some(policy) = parent.comment_ttl.as_ref() {
return clamp_policy(policy);
}
match class {
// Ruling: public and post-key-signed comments never auto-expire.
CommentClass::Public | CommentClass::PostKeySigned => CommentTtlRule::Never,
// Ruling: the open-slot stranger channel is the throwaway-ID
// retirement mechanism and the ONLY automatic TTL.
CommentClass::OpenSlot => CommentTtlRule::Window {
min_ttl_ms: OPEN_SLOT_TTL_MIN_MS,
max_ttl_ms: OPEN_SLOT_TTL_MAX_MS,
},
// Only reachable with `parent = None`, handled above.
CommentClass::Unverifiable => CommentTtlRule::UnknownParent,
}
}
/// Convenience: [`classify`] + [`rule_for`].
pub fn rule_for_comment(parent: Option<&Post>, comment: &InlineComment) -> CommentTtlRule {
rule_for(parent, classify(parent, comment))
}
/// Clamp an author-declared policy into what holders will store. Authors
/// may shorten retention freely; they may not buy more of a stranger's
/// disk than [`MAX_COMMENT_TTL_MS`].
fn clamp_policy(policy: &CommentTtlPolicy) -> CommentTtlRule {
let max = policy.max_ttl_ms.min(MAX_COMMENT_TTL_MS);
let min = policy.min_ttl_ms.min(max);
CommentTtlRule::Window { min_ttl_ms: min, max_ttl_ms: max }
}
/// Pick the `expires_at_ms` a NEW comment must carry under `rule`.
/// Returns the `0` sentinel for [`CommentTtlRule::Never`] and for
/// [`CommentTtlRule::UnknownParent`] (writer-side "assume no policy" —
/// see the module header; holders that hold the post confirm it).
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` shortens a
/// *randomized* window (`min < max` — i.e. the open-slot greeting
/// channel it was written for) to n seconds, in debug builds only. It
/// deliberately does NOT touch:
/// * [`CommentTtlRule::Never`] — "no expiry" is a correctness
/// property, not a duration knob; and
/// * FLAT windows (`min == max`), i.e. an author-declared policy such
/// as the registry post's 30 days. Shortening those would make every
/// registration expire in n seconds and pin the auto-renew loop
/// permanently "due" (`renew_registry_entries_if_due`).
pub fn draw_expiry(rule: CommentTtlRule, now_ms: u64) -> u64 {
match rule {
CommentTtlRule::Never | CommentTtlRule::UnknownParent => 0,
CommentTtlRule::Window { min_ttl_ms, max_ttl_ms } => {
if let Some(secs) = override_secs_for(min_ttl_ms, max_ttl_ms) {
return now_ms + secs * 1000;
}
if min_ttl_ms >= max_ttl_ms {
// Flat policy: a correctness constraint holders enforce
// exactly. No test override here — see the doc above.
return now_ms + min_ttl_ms;
}
use rand::Rng;
now_ms + rand::rng().random_range(min_ttl_ms..=max_ttl_ms)
}
}
}
/// Scope of the `ITSGOIN_TEST_TTL_SECS` debug override, as a pure
/// function of the window and the env value — the env read itself lives
/// in [`override_secs_for`]. A FLAT window (`min == max`) is an author
/// policy such as the registry's 30 days: a correctness constraint, not
/// a duration knob, so the override never touches it.
fn override_scope(min_ttl_ms: u64, max_ttl_ms: u64, secs: Option<u64>) -> Option<u64> {
match secs {
Some(s) if min_ttl_ms < max_ttl_ms => Some(s),
_ => None,
}
}
/// [`override_scope`] against the live environment (debug builds only).
fn override_secs_for(min_ttl_ms: u64, max_ttl_ms: u64) -> Option<u64> {
#[cfg(debug_assertions)]
{
let secs = std::env::var("ITSGOIN_TEST_TTL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok());
override_scope(min_ttl_ms, max_ttl_ms, secs)
}
#[cfg(not(debug_assertions))]
{
override_scope(min_ttl_ms, max_ttl_ms, None)
}
}
/// Holder-side check: does this comment's self-asserted expiry match the
/// rule its parent post imposes? Rejects in BOTH directions — a
/// no-expiry comment on a TTL'd post, and an over-long TTL on the
/// stranger channel.
pub fn ttl_ok(rule: CommentTtlRule, timestamp_ms: u64, expires_at_ms: u64) -> bool {
match rule {
CommentTtlRule::Never => expires_at_ms == 0,
// Parent unheld: bounded TTLs only — never the `0` sentinel.
CommentTtlRule::UnknownParent => ttl_ok(
CommentTtlRule::Window { min_ttl_ms: 0, max_ttl_ms: MAX_COMMENT_TTL_MS },
timestamp_ms,
expires_at_ms,
),
CommentTtlRule::Window { min_ttl_ms, max_ttl_ms } => {
// The test override shortens RANDOMIZED windows below their
// real floor; accept anything inside the ceiling then. Flat
// policies (min == max) keep their floor — they are a
// correctness constraint, not a duration knob.
let min_ttl_ms = if override_secs_for(min_ttl_ms, max_ttl_ms).is_some() {
0
} else {
min_ttl_ms
};
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else {
return false; // 0 sentinel or backdated expiry
};
if expires_at_ms == 0 {
return false;
}
ttl + TTL_SLACK_MS >= min_ttl_ms && ttl <= max_ttl_ms.saturating_add(TTL_SLACK_MS)
}
}
}
/// Holder-side ceiling on live comments in a post's open slot: the
/// author's declared limit if any, clamped to the holder's own default
/// for that slot kind. `None` slot ⇒ caller doesn't apply a cap.
pub fn open_slot_limit(decl: &crate::types::OpenSlotDecl, holder_default: u64) -> u64 {
match decl.max_comments {
Some(n) => u64::from(n).min(holder_default),
None => holder_default,
}
}
/// Holder default ceiling per open-slot kind. Greeting slots are the
/// flood-exposed surface (round 8: rate caps + size buckets ARE the
/// flood limits, no PoW anywhere); registry chains are bounded by the
/// 10MB blob split instead.
pub fn holder_default_open_slot_limit(kind: OpenSlotKind) -> u64 {
match kind {
OpenSlotKind::Greeting => crate::connection::MAX_GREETINGS_PER_BIO,
OpenSlotKind::Registry => u64::MAX,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FoFCommentGating, OpenSlotDecl, PostId};
fn post_with(open_slot: Option<OpenSlotDecl>, policy: Option<CommentTtlPolicy>) -> Post {
Post {
author: [1u8; 32],
content: "p".into(),
attachments: vec![],
timestamp_ms: 0,
fof_gating: open_slot.map(|d| FoFCommentGating {
slot_binder_nonce: [0u8; 32],
pub_post_set: vec![[0u8; 32]; 8],
wrap_slots: vec![],
revocation_list: vec![],
open_slot: Some(d),
}),
supersedes_post_id: None,
comment_ttl: policy,
}
}
fn comment(pub_x_index: Option<u32>) -> InlineComment {
InlineComment {
author: [2u8; 32],
post_id: PostId::default(),
content: String::new(),
timestamp_ms: 1_000,
signature: vec![],
deleted_at: None,
ref_post_id: None,
pub_x_index,
group_sig: None,
encrypted_payload: None,
expires_at_ms: 0,
}
}
fn greeting_slot(idx: u32) -> OpenSlotDecl {
OpenSlotDecl {
slot_index: idx,
kind: OpenSlotKind::Greeting,
body_bucket: 1024,
max_comments: None,
}
}
#[test]
fn class_defaults_match_the_ruling() {
let p = post_with(Some(greeting_slot(3)), None);
// Public comment → never expires.
assert_eq!(rule_for_comment(Some(&p), &comment(None)), CommentTtlRule::Never);
// Post-key-signed (non-open slot) → never expires.
assert_eq!(rule_for_comment(Some(&p), &comment(Some(1))), CommentTtlRule::Never);
// Open slot → randomized 30365d.
assert_eq!(
rule_for_comment(Some(&p), &comment(Some(3))),
CommentTtlRule::Window {
min_ttl_ms: OPEN_SLOT_TTL_MIN_MS,
max_ttl_ms: OPEN_SLOT_TTL_MAX_MS
}
);
}
#[test]
fn author_policy_overrides_every_class() {
let flat = CommentTtlPolicy::flat(30 * 24 * 3600 * 1000);
let p = post_with(Some(greeting_slot(3)), Some(flat));
for idx in [None, Some(1), Some(3)] {
assert_eq!(
rule_for_comment(Some(&p), &comment(idx)),
CommentTtlRule::Window {
min_ttl_ms: flat.min_ttl_ms,
max_ttl_ms: flat.max_ttl_ms
},
"policy must win for every signing class",
);
}
}
#[test]
fn policy_is_clamped_to_the_holder_ceiling() {
let greedy = CommentTtlPolicy { min_ttl_ms: 0, max_ttl_ms: u64::MAX };
let p = post_with(None, Some(greedy));
assert_eq!(
rule_for(Some(&p), CommentClass::Public),
CommentTtlRule::Window { min_ttl_ms: 0, max_ttl_ms: MAX_COMMENT_TTL_MS }
);
}
#[test]
fn ttl_ok_enforces_both_directions() {
let flat = CommentTtlRule::Window {
min_ttl_ms: 30 * 24 * 3600 * 1000,
max_ttl_ms: 30 * 24 * 3600 * 1000,
};
let t = 10_000_000u64;
assert!(ttl_ok(flat, t, t + 30 * 24 * 3600 * 1000));
assert!(!ttl_ok(flat, t, 0), "no-expiry must fail a flat-TTL policy");
assert!(!ttl_ok(flat, t, t + 365 * 24 * 3600 * 1000), "over-long must fail");
assert!(ttl_ok(CommentTtlRule::Never, t, 0));
assert!(!ttl_ok(CommentTtlRule::Never, t, t + 1000), "TTL must fail a Never rule");
}
#[test]
fn draw_expiry_honours_the_never_sentinel() {
assert_eq!(draw_expiry(CommentTtlRule::Never, 5_000), 0);
let w = CommentTtlRule::Window { min_ttl_ms: 100, max_ttl_ms: 100 };
assert_eq!(draw_expiry(w, 5_000), 5_100);
}
/// Ruling / module header: a holder that does not hold the parent
/// post must never grant PERMANENCE on evidence it cannot read.
#[test]
fn unknown_parent_refuses_permanence_but_allows_bounded() {
assert_eq!(
rule_for_comment(None, &comment(None)),
CommentTtlRule::UnknownParent,
"no parent → policy unseen, not the class default",
);
assert_eq!(rule_for_comment(None, &comment(Some(3))), CommentTtlRule::UnknownParent);
let t = 10_000_000u64;
assert!(
!ttl_ok(CommentTtlRule::UnknownParent, t, 0),
"the never-expires sentinel must NOT be honoured on an unheld parent",
);
assert!(
ttl_ok(CommentTtlRule::UnknownParent, t, t + 40 * 24 * 3600 * 1000),
"a bounded TTL self-heals and is accepted",
);
assert!(
!ttl_ok(CommentTtlRule::UnknownParent, t, t + MAX_COMMENT_TTL_MS + TTL_SLACK_MS * 2),
"still capped by the holder ceiling",
);
// Writer side stays optimistic: assume "no policy" = no expiry.
assert_eq!(draw_expiry(CommentTtlRule::UnknownParent, 5_000), 0);
}
/// A slot claim on a post we do not hold is unverifiable — it must
/// not masquerade as evidence of post-key membership.
#[test]
fn slot_claim_on_unheld_parent_is_unverifiable() {
assert_eq!(classify(None, &comment(Some(3))), CommentClass::Unverifiable);
assert_eq!(classify(None, &comment(None)), CommentClass::Public);
let p = post_with(Some(greeting_slot(3)), None);
assert_eq!(classify(Some(&p), &comment(Some(3))), CommentClass::OpenSlot);
assert_eq!(classify(Some(&p), &comment(Some(1))), CommentClass::PostKeySigned);
}
/// The `ITSGOIN_TEST_TTL_SECS` hook was written for the greeting
/// sweep. It must NOT shorten a flat author policy: with the
/// registry's flat 30d shortened to n seconds every registration
/// would expire immediately and the auto-renew check
/// (`renew_registry_entries_if_due`) would be permanently "due",
/// re-registering on every tick. Tested through the pure scope
/// function so no test has to mutate the process environment.
#[test]
fn test_ttl_override_skips_flat_policies() {
let flat = REGISTRATION_FLAT_MS;
assert_eq!(
override_scope(flat, flat, Some(5)), None,
"a flat author policy (registry 30d) ignores the override",
);
assert_eq!(
override_scope(OPEN_SLOT_TTL_MIN_MS, OPEN_SLOT_TTL_MAX_MS, Some(5)),
Some(5),
"the randomized open-slot window is what the hook is for",
);
assert_eq!(
override_scope(OPEN_SLOT_TTL_MIN_MS, OPEN_SLOT_TTL_MAX_MS, None), None,
"no env var, no override",
);
// …and the same scoping governs the holder-side floor relaxation.
let t = 1_000_000u64;
assert!(
!ttl_ok(CommentTtlRule::Window { min_ttl_ms: flat, max_ttl_ms: flat }, t, t + 1000),
"a flat policy keeps its floor in debug builds too",
);
}
const REGISTRATION_FLAT_MS: u64 = 30 * 24 * 3600 * 1000;
#[test]
fn open_slot_limit_clamps_up_but_not_down() {
let mut d = greeting_slot(0);
assert_eq!(open_slot_limit(&d, 64), 64);
d.max_comments = Some(5);
assert_eq!(open_slot_limit(&d, 64), 5, "author may lower the ceiling");
d.max_comments = Some(10_000);
assert_eq!(open_slot_limit(&d, 64), 64, "author may not raise it");
}
}

View file

@ -6212,6 +6212,7 @@ impl ConnectionManager {
let parent = storage.get_post(&payload.post_id).ok().flatten();
if accept_incoming_comment(
&storage,
&payload.post_id,
parent.as_ref(),
&policy,
&followers_set,
@ -9026,11 +9027,16 @@ fn now_ms() -> u64 {
/// Per-bio unexpired greeting cap (holder-side flood limit; round 8:
/// rate caps + size buckets ARE the flood limits, no PoW anywhere).
///
/// v0.8: this is now the holder's DEFAULT ceiling. An author may declare
/// a lower limit in `OpenSlotDecl.max_comments`
/// ([`crate::comment_ttl::open_slot_limit`]); they may never raise it.
pub const MAX_GREETINGS_PER_BIO: u64 = 64;
/// Max accepted TTL: a comment may not claim an expiry more than 366
/// days out (ordinary TTLs top out at 365d).
pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000;
/// days out (open-slot TTLs top out at 365d). Alias of the canonical
/// definition in [`crate::comment_ttl`].
pub use crate::comment_ttl::MAX_COMMENT_TTL_MS;
/// Generous ceiling on a sealed-greeting `encrypted_payload` relative to
/// the declared plaintext bucket: sealed blob (32B eph + 4B len + bucket
@ -9040,30 +9046,55 @@ fn greeting_max_payload_bytes(body_bucket: u16) -> usize {
}
/// The single accept/drop decision for an incoming comment, shared by
/// all three ingest sites. `parent_post` is the locally-held parent (if
/// any); `policy` + `followers_set` are the parent's engagement policy
/// inputs. Returns `true` = store, `false` = drop without forwarding.
/// all three ingest sites. `parent_post_id` is the post the ENVELOPE
/// (header diff / pulled `BlobHeader`) is about and `parent_post` is
/// that post as we hold it (if we do); `policy` + `followers_set` are
/// that post's engagement policy inputs. Returns `true` = store,
/// `false` = drop without forwarding.
///
/// Checks, in order (spec A3 §T9):
/// 0. envelope binding: `comment.post_id` MUST equal `parent_post_id`.
/// Every check below resolves against the ENVELOPE's post while the
/// row is stored under `comment.post_id`; letting the two differ
/// lets an attacker pick a permissive post as the envelope and have
/// its (absent) policy/gating applied to a comment landing on the
/// victim's bio or the registry post;
/// 1. identity signature (digest v2, expiry included);
/// 2. TTL sanity: v0.8 REQUIRES a TTL — `expires_at_ms == 0` rejected,
/// already-expired rejected (stateless re-ingest guard, the
/// comment-analog of `deleted_posts`), >366d rejected;
/// 2. TTL sanity: `0` is the never-expires sentinel; a non-zero expiry
/// must be unexpired (stateless re-ingest guard, the comment-analog
/// of `deleted_posts`) and ≤366d out;
/// 2b. retention: (parent policy × signing class) via
/// [`crate::comment_ttl::ttl_ok`] — rejects in BOTH directions (a
/// never-expires comment on a TTL'd post, an over-long TTL on the
/// stranger channel). An unheld parent accepts bounded TTLs only;
/// 3. policy (blocklist / None / FollowersOnly) + FoF four-check gated
/// on `post.fof_gating.is_some()` — NOT the `CommentPolicy` enum
/// (closing the pre-existing dead-gate gap: nothing ever set the
/// FriendsOfFriends policy, so the enum arm was unreachable);
/// 4. open-slot size buckets + per-bio greeting cap;
/// 4. open-slot size buckets + the author-declared open-slot limit,
/// clamped to the holder's own default for the slot kind;
/// 5. registry-post entry validation + newest-wins upsert.
pub fn accept_incoming_comment(
storage: &crate::storage::Storage,
parent_post_id: &crate::types::PostId,
parent_post: Option<&crate::types::Post>,
policy: &crate::types::CommentPolicy,
followers_set: &HashSet<NodeId>,
comment: &crate::types::InlineComment,
now: u64,
) -> bool {
// 0. Tombstone injection guard: the identity signature does NOT
// 0. Envelope binding. `parent_post`, `policy` and `followers_set`
// all describe `parent_post_id`; `store_comment` files the row under
// `comment.post_id`. If those differ, every decision below is made
// about one post and applied to another — e.g. an ungated public
// post as the envelope (no gating ⇒ no four-check, no open-slot cap,
// no retention policy) carrying a never-expiring comment addressed
// to the victim's bio open slot or to the registry post.
if comment.post_id != *parent_post_id {
return false;
}
// 0b. Tombstone injection guard: the identity signature does NOT
// cover `deleted_at`, so an attacker could take any valid signed
// comment off the chain, set `deleted_at`, and re-send it — an
// unsigned effective delete (store_comment's upsert propagates the
@ -9077,16 +9108,18 @@ pub fn accept_incoming_comment(
return false;
}
// 2. TTL sanity.
if comment.expires_at_ms == 0 {
return false; // v0.8 requires a TTL
}
// 2. TTL sanity. `expires_at_ms == 0` is the NEVER-EXPIRES sentinel
// (inside the signed digest, so the author committed to it). Whether
// it is legal here is decided in step 2b against the parent post's
// retention policy — see `crate::comment_ttl`.
if comment.expires_at_ms != 0 {
if comment.expires_at_ms <= now {
return false; // already expired — never (re-)ingest
}
if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) {
return false; // TTL beyond the allowed window
}
}
// Future-dated timestamps: allow only small clock skew. Without this
// a registration ~336d in the future permanently wins newest-wins
// against honest renewals and pins itself atop timestamp-sorted
@ -9095,6 +9128,21 @@ pub fn accept_incoming_comment(
return false;
}
// 2b. RETENTION: the comment's self-asserted expiry must match the
// rule imposed by (parent post's comment-TTL policy × the comment's
// signing class). Rejects in both directions — a no-expiry comment
// on a TTL'd post (permanent registry squat) and an over-long TTL on
// the stranger channel (permanent squat on someone's greeting
// budget). Writer-side agreement is not enough: the TTL is
// self-asserted, so holders decide.
if !crate::comment_ttl::ttl_ok(
crate::comment_ttl::rule_for_comment(parent_post, comment),
comment.timestamp_ms,
comment.expires_at_ms,
) {
return false;
}
// 3a. Ambient policy checks.
if policy.blocklist.contains(&comment.author) {
return false;
@ -9171,12 +9219,6 @@ pub fn accept_incoming_comment(
if !comment.content.is_empty() {
return false; // greeting bodies are sealed-only
}
let live = storage
.count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index)
.unwrap_or(u64::MAX);
if live >= MAX_GREETINGS_PER_BIO {
return false;
}
}
crate::types::OpenSlotKind::Registry => {
if comment.content.len() > decl.body_bucket as usize {
@ -9184,6 +9226,23 @@ pub fn accept_incoming_comment(
}
}
}
// v0.8: author-declarable LIMIT on live open-slot
// comments, clamped to the holder's own ceiling for the
// kind (authors may lower, never raise — the storage
// being spent is the holder's). Refusal is the separate,
// structural case: no open slot declared at all.
let limit = crate::comment_ttl::open_slot_limit(
decl,
crate::comment_ttl::holder_default_open_slot_limit(decl.kind),
);
if limit != u64::MAX {
let live = storage
.count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index)
.unwrap_or(u64::MAX);
if live >= limit {
return false;
}
}
}
}
}
@ -9193,9 +9252,11 @@ pub fn accept_incoming_comment(
if crate::registry::parse_registration(&comment.content).is_err() {
return false;
}
if !crate::registry::registration_ttl_ok(comment.timestamp_ms, comment.expires_at_ms) {
return false;
}
// TTL is NOT re-checked here: step 2b already validated it
// against the registry post's own `comment_ttl` policy (flat
// 30d), which covers every comment on the post — registrations,
// duplicate-reports, anything — not just the ones that parse as
// registration entries.
// Newest-wins per persona: READ-ONLY check here — drop the
// incoming comment when a newer entry is already stored. The
// destructive half (hard-deleting the older row) happens in the
@ -9254,7 +9315,15 @@ pub fn ingest_header_comments(
.collect();
let mut stored = 0usize;
for comment in &header.comments {
if accept_incoming_comment(storage, parent.as_ref(), &policy, &followers, comment, now) {
if accept_incoming_comment(
storage,
&header.post_id,
parent.as_ref(),
&policy,
&followers,
comment,
now,
) {
if storage.store_comment(comment).is_ok() {
stored += 1;
// Registry newest-wins pruning only after a SUCCESSFUL
@ -9315,6 +9384,16 @@ mod accept_gate_tests {
/// Storage + a stored bio post with a Greeting open slot, owned by
/// a persona with a V_me. Returns (storage, bio_post_id, bio_post).
fn setup_greeting_bio(seed_byte: u8) -> (Storage, PostId, Post) {
setup_greeting_bio_with(seed_byte, None, None)
}
/// As above, plus the author-declared open-slot LIMIT and an
/// optional per-post comment-retention policy.
fn setup_greeting_bio_with(
seed_byte: u8,
max_comments: Option<u32>,
comment_ttl: Option<crate::types::CommentTtlPolicy>,
) -> (Storage, PostId, Post) {
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(seed_byte);
s.upsert_posting_identity(&PostingIdentity {
@ -9328,7 +9407,10 @@ mod accept_gate_tests {
s.insert_own_vouch_key(&alice_id, 1, &v_me, 1000).unwrap();
let built = crate::fof::build_fof_comment_gating(
&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)),
&s,
&alice_id,
Some(crate::fof::OpenSlotSpec::new(OpenSlotKind::Greeting, 1024)
.with_limit(max_comments)),
).unwrap().expect("gating built");
let post = Post {
author: alice_id,
@ -9337,6 +9419,7 @@ mod accept_gate_tests {
timestamp_ms: 3000,
fof_gating: Some(built.gating),
supersedes_post_id: None,
comment_ttl,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_intent(
@ -9369,8 +9452,28 @@ mod accept_gate_tests {
).unwrap()
}
/// Envelope == the post the comment names (the honest case).
fn gate(s: &Storage, post: Option<&Post>, c: &InlineComment) -> bool {
accept_incoming_comment(s, post, &CommentPolicy::default(), &HashSet::new(), c, now())
gate_with_envelope(s, &c.post_id, post, c)
}
/// Explicit envelope post id — lets a test offer a comment under a
/// DIFFERENT post than the one it names.
fn gate_with_envelope(
s: &Storage,
envelope_id: &PostId,
post: Option<&Post>,
c: &InlineComment,
) -> bool {
accept_incoming_comment(
s,
envelope_id,
post,
&CommentPolicy::default(),
&HashSet::new(),
c,
now(),
)
}
/// Pull-path variant: wrap the comment in a BlobHeader and run the
@ -9504,6 +9607,95 @@ mod accept_gate_tests {
assert!(!gate(&s, None, &c));
}
/// The retention rule, the FoF four-check, the size bucket and the
/// open-slot limit all resolve against the ENVELOPE's post, while
/// the row is filed under `comment.post_id`. Offering a comment
/// under a different post than it names must be rejected outright:
/// an ungated public post as the envelope has no gating, no policy
/// and no open-slot cap, which would otherwise buy a PERMANENT
/// squat on the victim's bio greeting slot (or the registry).
#[test]
fn comment_naming_a_different_post_than_the_envelope_rejected() {
let (s, bio_id, bio) = setup_greeting_bio(17);
let decoy = Post {
author: bio.author,
content: "an ordinary ungated post".into(),
attachments: vec![],
timestamp_ms: 4000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let decoy_id = crate::content::compute_post_id(&decoy);
s.store_post_with_intent(
&decoy_id, &decoy, &PostVisibility::Public, &VisibilityIntent::Public,
).unwrap();
// A NEVER-EXPIRING greeting addressed to the bio's open slot…
let c = make_greeting(&bio, &bio_id, 59, now(), 0);
// …smuggled in under the decoy envelope.
assert!(
!gate_with_envelope(&s, &decoy_id, Some(&decoy), &c),
"diff path: the comment must name the envelope's post",
);
let header = BlobHeader {
post_id: decoy_id,
author: decoy.author,
reactions: vec![],
comments: vec![c.clone()],
policy: CommentPolicy::default(),
updated_at: now(),
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
prior_author: None,
};
assert_eq!(
ingest_header_comments(&s, &header, now()), 0,
"pull path: the comment must name the envelope's post",
);
assert!(
s.get_comments_with_tombstones(&bio_id).unwrap().is_empty(),
"nothing may land on the bio through another post's envelope",
);
// Sanity: the honest envelope + the ruling's TTL still works.
let ok = make_greeting(&bio, &bio_id, 60, now(), now() + 40 * 24 * 3600 * 1000);
assert!(gate(&s, Some(&bio), &ok), "honest greeting still accepted");
}
/// Permanence is never granted on evidence we cannot read: a holder
/// that does not hold the parent post has never seen its retention
/// policy, so it stores bounded TTLs (they self-heal) but refuses
/// the `0` never-expires sentinel.
#[test]
fn never_expires_comment_on_an_unheld_parent_rejected() {
let (_s, _post, post_id, forever) = public_post_and_comment(94, 0);
let holder = temp_storage(); // does NOT hold the parent post
let hdr = |c: &InlineComment| BlobHeader {
post_id,
author: c.author,
reactions: vec![],
comments: vec![c.clone()],
policy: CommentPolicy::default(),
updated_at: now(),
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
prior_author: None,
};
assert!(!gate(&holder, None, &forever), "diff path: no permanence on an unseen policy");
assert_eq!(ingest_header_comments(&holder, &hdr(&forever), now()), 0, "pull path too");
let t = now();
let (_s2, _p2, _id2, bounded) = public_post_and_comment(94, t + 40 * 24 * 3600 * 1000);
assert!(
gate(&holder, None, &bounded),
"a bounded TTL under an unheld parent is accepted — it self-heals",
);
}
/// Registration matrix: valid entry accepted; wrong TTL rejected;
/// bad shape rejected; older-than-stored dropped — both paths.
#[test]
@ -9581,6 +9773,277 @@ mod accept_gate_tests {
assert_eq!(rows[0].timestamp_ms, t + 1000);
}
// ---- v0.8 comment-retention taxonomy (ruling 2026-08-03) ----
//
// Retention = (parent post's comment-TTL policy) × (comment's
// signing class). Public and post-key-signed comments never expire
// (`expires_at_ms == 0` sentinel); the open-slot stranger channel is
// the only class with an automatic TTL; an author policy (registry
// posts: flat 30d) overrides every class.
/// A plain public post + a plain public comment on it, signed under
/// the commenter's real posting key (no slot claim).
fn public_post_and_comment(
seed_byte: u8,
expires_at_ms: u64,
) -> (Storage, Post, PostId, InlineComment) {
let s = temp_storage();
let (author_id, _) = make_persona(seed_byte);
let post = Post {
author: author_id,
content: "hello".into(),
attachments: vec![],
timestamp_ms: 3000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_intent(
&post_id, &post, &PostVisibility::Public, &VisibilityIntent::Public,
).unwrap();
let (commenter, commenter_seed) = make_persona(seed_byte.wrapping_add(1));
let ts = now();
let content = "nice post";
let signature = crate::crypto::sign_comment(
&commenter_seed, &commenter, &post_id, content, ts, None, expires_at_ms,
);
let c = InlineComment {
author: commenter,
post_id,
content: content.into(),
timestamp_ms: ts,
signature,
deleted_at: None,
ref_post_id: None,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms,
};
(s, post, post_id, c)
}
/// Ruling #2: public comments never auto-expire — the `0` sentinel
/// is accepted on both ingest paths AND survives a sweep run far
/// past any TTL that would previously have been drawn for it.
#[test]
fn public_comment_has_no_expiry_and_survives_the_sweep() {
let (s, post, post_id, c) = public_post_and_comment(90, 0);
assert!(gate(&s, Some(&post), &c), "no-expiry public comment accepted (diff)");
assert!(gate_via_pull(&s, &post_id, &post.author, &c), "…and on the pull path");
// Sweep a full year past the old 30365d TTL window.
let far_future = now() + 400 * 24 * 3600 * 1000;
s.expire_comments(far_future).unwrap();
assert_eq!(
s.get_comments(&post_id).unwrap().len(),
1,
"a never-expiring comment must survive any sweep",
);
}
/// Holder-side enforcement, the "no auto-expiry" direction: a public
/// comment that self-assigns a TTL contradicts its parent's rule and
/// is dropped (the taxonomy is deterministic — divergence is a bug
/// or an attack, never a preference).
#[test]
fn public_comment_with_self_assigned_ttl_rejected() {
let t = now();
let (s, post, post_id, c) = public_post_and_comment(92, t + 40 * 24 * 3600 * 1000);
assert!(!gate(&s, Some(&post), &c));
assert!(!gate_via_pull(&s, &post_id, &post.author, &c));
}
/// Ruling #3: a post-key-signed (FoF/member) comment carries the `0`
/// sentinel and is accepted; the same comment with a TTL is not.
#[test]
fn post_key_signed_comment_has_no_expiry() {
let (s, post_id, post) = setup_greeting_bio(93);
// Alice's own V_me unlocks one of the REAL (non-open) slots.
let unlock = crate::fof::find_unlock_for_post(&s, &post).unwrap().expect("member unlock");
let open_idx = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index;
assert_ne!(unlock.slot_index, open_idx, "member slot, not the open slot");
let identity = s.get_posting_identity(&unlock.persona_id).unwrap().unwrap();
let nonce = post.fof_gating.as_ref().unwrap().slot_binder_nonce;
let t = now();
let never = crate::fof::build_fof_comment(
&post_id, &unlock, &nonce, &identity.node_id, &identity.secret_seed,
"members only", None, t, 0,
).unwrap();
assert!(gate(&s, Some(&post), &never), "member comment never expires");
assert!(gate_via_pull(&s, &post_id, &post.author, &never));
let ttl_d = crate::fof::build_fof_comment(
&post_id, &unlock, &nonce, &identity.node_id, &identity.secret_seed,
"members only", None, t + 1, t + 40 * 24 * 3600 * 1000,
).unwrap();
assert!(!gate(&s, Some(&post), &ttl_d), "a member comment may not self-assign a TTL");
}
/// Ruling #4: the open-slot stranger channel keeps the randomized
/// 30365d TTL, and a self-assigned over-long TTL is rejected even
/// when it is under the absolute 366-day holder ceiling.
#[test]
fn greeting_ttl_window_enforced_holder_side() {
let (s, post_id, post) = setup_greeting_bio(94);
let t = now();
let day = 24 * 3600 * 1000u64;
// Inside the window: fine.
let ok = make_greeting(&post, &post_id, 95, t, t + 200 * day);
assert!(gate(&s, Some(&post), &ok));
// Over-long but under MAX_COMMENT_TTL_MS (366d) → the POLICY
// window is what rejects it, not the absolute ceiling.
let long = make_greeting(&post, &post_id, 96, t, t + 365 * day + 6 * 3600 * 1000);
assert!(long.expires_at_ms < t + MAX_COMMENT_TTL_MS, "under the absolute ceiling");
assert!(!gate(&s, Some(&post), &long), "over-long greeting TTL rejected");
assert!(!gate_via_pull(&s, &post_id, &post.author, &long));
// Too short (a 1-hour self-retiring greeting is also a mismatch).
let short = make_greeting(&post, &post_id, 97, t, t + 3600 * 1000);
assert!(!gate(&s, Some(&post), &short));
}
/// Ruling #1, holder-side enforcement, the other direction: a
/// never-expires comment submitted to a post whose policy demands a
/// TTL (the registry post: flat 30d) is dropped. Without this a
/// spammer buys a permanent registry slot.
#[test]
fn no_expiry_comment_on_registry_post_rejected() {
let s = temp_storage();
crate::registry::materialize_registry_post(&s).unwrap();
let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap();
let (persona_id, persona_seed) = make_persona(98);
let t = now();
let content = r#"{"v":1,"name":"Mallory","keywords":[]}"#;
let build = |expires: u64| -> InlineComment {
let unlock = crate::fof::derive_open_slot_unlock(&registry, &persona_id).unwrap();
let group_sig = {
use ed25519_dalek::Signer;
let signer = SigningKey::from_bytes(&unlock.priv_x_seed);
let mut to_sign = Vec::new();
to_sign.extend_from_slice(content.as_bytes());
to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID);
to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes());
signer.sign(&to_sign).to_bytes().to_vec()
};
let signature = crate::crypto::sign_comment(
&persona_seed, &persona_id, &crate::registry::REGISTRY_POST_ID,
content, t, None, expires,
);
InlineComment {
author: persona_id,
post_id: crate::registry::REGISTRY_POST_ID,
content: content.into(),
timestamp_ms: t,
signature,
deleted_at: None,
ref_post_id: None,
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: None,
expires_at_ms: expires,
}
};
let never = build(0);
assert!(!gate(&s, Some(&registry), &never), "no-expiry rejected on a TTL'd post");
assert!(!gate_via_pull(
&s, &crate::registry::REGISTRY_POST_ID, &registry.author, &never,
));
// Sanity: the same comment with the post's flat 30d is fine.
let ok = build(t + crate::registry::REGISTRATION_TTL_MS);
assert!(gate(&s, Some(&registry), &ok));
}
/// Ruling #1, the part the shipped code got wrong: the 30-day fuse
/// belongs to the POST, not to "registrations". ANY comment on a
/// registry post — a duplicate-report, anything a future release
/// adds — resolves to the same flat-30d rule, and it does so through
/// the generic per-post policy, with no `is_registry_post` branch.
#[test]
fn every_comment_class_on_a_registry_post_gets_the_flat_30d_rule() {
use crate::comment_ttl::{rule_for, CommentClass, CommentTtlRule};
let s = temp_storage();
crate::registry::materialize_registry_post(&s).unwrap();
let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap();
let expected = CommentTtlRule::Window {
min_ttl_ms: crate::registry::REGISTRATION_TTL_MS,
max_ttl_ms: crate::registry::REGISTRATION_TTL_MS,
};
for class in [CommentClass::Public, CommentClass::PostKeySigned, CommentClass::OpenSlot] {
assert_eq!(rule_for(Some(&registry), class), expected, "{:?}", class);
}
// And it really is the post's own field driving this.
assert_eq!(registry.comment_ttl, Some(crate::registry::REGISTRY_COMMENT_TTL_POLICY));
}
/// Ruling #6: the author-declared open-slot LIMIT is enforced
/// holder-side, below the holder's own default ceiling.
#[test]
fn author_declared_open_slot_limit_enforced() {
let (s, post_id, post) = setup_greeting_bio_with(99, Some(2), None);
let t = now();
let slot = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap();
assert_eq!(slot.max_comments, Some(2), "limit rides the signed declaration");
let slot_index = slot.slot_index;
for i in 0..2u64 {
let c = make_greeting(&post, &post_id, 100 + i as u8, t + i, t + 40 * 24 * 3600 * 1000);
assert!(gate(&s, Some(&post), &c), "under the author's limit");
s.store_comment(&c).unwrap();
}
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, slot_index).unwrap(), 2);
let over = make_greeting(&post, &post_id, 110, t + 2, t + 40 * 24 * 3600 * 1000);
assert!(!gate(&s, Some(&post), &over), "author's limit enforced (diff)");
assert!(!gate_via_pull(&s, &post_id, &post.author, &over), "…and on the pull path");
}
/// An author cannot RAISE the ceiling past the holder's own default:
/// the storage being spent is the holder's.
#[test]
fn author_cannot_raise_the_open_slot_ceiling() {
let (_s, _post_id, post) = setup_greeting_bio_with(111, Some(100_000), None);
let decl = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap();
assert_eq!(
crate::comment_ttl::open_slot_limit(
decl,
crate::comment_ttl::holder_default_open_slot_limit(decl.kind),
),
MAX_GREETINGS_PER_BIO,
);
}
/// An author-set policy on an ORDINARY post works exactly like the
/// registry's — this is the forward path for a future "comments on
/// this post expire in N days" composer option, with no new
/// mechanism: set `Post.comment_ttl` and every holder enforces it.
#[test]
fn author_set_policy_on_an_ordinary_post_is_enforced() {
let day = 24 * 3600 * 1000u64;
let (s, post_id, post) = setup_greeting_bio_with(
112, None, Some(crate::types::CommentTtlPolicy::flat(7 * day)),
);
let t = now();
let matching = make_greeting(&post, &post_id, 113, t, t + 7 * day);
assert!(gate(&s, Some(&post), &matching), "matches the author's 7d policy");
// The class default (30365d) no longer applies — the post's own
// policy wins for every class.
let class_default = make_greeting(&post, &post_id, 114, t, t + 200 * day);
assert!(!gate(&s, Some(&post), &class_default));
let never = make_greeting(&post, &post_id, 115, t, 0);
assert!(!gate(&s, Some(&post), &never));
}
#[test]
fn delete_comment_authorization_matrix() {
let (author_id, author_seed) = make_persona(80);
@ -9662,6 +10125,7 @@ mod accept_gate_tests {
timestamp_ms: old_bio.timestamp_ms + 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let new_bio_id = crate::content::compute_post_id(&new_bio);
s.store_post_with_intent(

View file

@ -25,6 +25,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id1 = compute_post_id(&post);
let id2 = compute_post_id(&post);
@ -40,6 +41,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post2 = Post {
author: [1u8; 32],
@ -48,6 +50,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
}
@ -61,6 +64,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id = compute_post_id(&post);
assert!(verify_post_id(&id, &post));

View file

@ -166,6 +166,7 @@ pub fn build_delete_control_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -195,6 +196,7 @@ pub fn build_visibility_control_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -227,6 +229,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
@ -257,6 +260,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();

View file

@ -53,6 +53,13 @@ pub struct ExportedPost {
pub visibility_json: String,
pub header_json: Option<String>,
pub intent: Option<String>,
/// v0.8: the post's comment-retention policy
/// (`crate::comment_ttl`). It is INSIDE `PostId = BLAKE3(Post)`, so
/// an export that drops it produces an archive whose posts fail
/// `verify_post_id` on import and are skipped as forged. Absent in
/// pre-v0.8 archives → `None`, which is what those posts carried.
#[serde(default)]
pub comment_ttl_json: Option<String>,
}
/// Result of an export operation.
@ -280,6 +287,10 @@ async fn gather_posts(
visibility_json: serde_json::to_string(vis).unwrap_or_default(),
header_json: header,
intent,
comment_ttl_json: post
.comment_ttl
.as_ref()
.and_then(|p| serde_json::to_string(p).ok()),
});
// Collect blob CIDs from attachments

View file

@ -37,16 +37,40 @@ pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
///
/// Side effect: this function is pure; no storage writes. The caller
/// owns persisting the resulting Post.
/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is
/// `open_slot`: when `Some(spec)`, one EXTRA real slot is
/// sealed under the derivable `V_open =
/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in
/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots).
/// The open slot is a REAL wrap slot sealed by the normal path; what
/// makes it open is only that its V_x is derivable by anyone.
/// `None` = the post REFUSES stranger comments entirely (structural
/// refusal); `spec.max_comments` is the softer author-declared LIMIT.
/// What kind of open slot to attach at publish time, and the author's
/// optional LIMIT on how many live comments it will accept. Mirrors
/// [`OpenSlotDecl`] minus `slot_index` (assigned by the shuffle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenSlotSpec {
pub kind: OpenSlotKind,
pub body_bucket: u16,
/// `None` = holder default for the kind. See
/// [`crate::comment_ttl::open_slot_limit`].
pub max_comments: Option<u32>,
}
impl OpenSlotSpec {
pub const fn new(kind: OpenSlotKind, body_bucket: u16) -> Self {
Self { kind, body_bucket, max_comments: None }
}
pub const fn with_limit(mut self, max_comments: Option<u32>) -> Self {
self.max_comments = max_comments;
self
}
}
pub fn build_fof_comment_gating(
storage: &Storage,
author_persona_id: &NodeId,
open_slot: Option<(OpenSlotKind, u16)>,
open_slot: Option<OpenSlotSpec>,
) -> Result<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// The author's own V_me appears with owner=author_persona_id.
@ -169,10 +193,11 @@ pub fn build_fof_comment_gating(
}
let open_slot_decl = match (open_slot, open_slot_index) {
(Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl {
(Some(spec), Some(slot_index)) => Some(OpenSlotDecl {
slot_index,
kind,
body_bucket,
kind: spec.kind,
body_bucket: spec.body_bucket,
max_comments: spec.max_comments,
}),
_ => None,
};
@ -662,6 +687,12 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
MAX_OPEN_SLOT_BODY_BUCKET,
);
}
// v0.8: an author-declared limit of 0 is not "refuse" — refusal
// is declaring no open slot at all. A zero here is malformed
// (and would be an invisible black hole for senders).
if decl.max_comments == Some(0) {
anyhow::bail!("open_slot.max_comments = 0 — omit the open slot to refuse instead");
}
}
Ok(())
@ -1222,6 +1253,7 @@ mod tests {
timestamp_ms: 3000,
fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Bob's device unlocks the post via his V_me (= v_x_bob).
@ -1305,6 +1337,7 @@ mod tests {
author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1396,6 +1429,7 @@ mod tests {
author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1498,6 +1532,7 @@ mod tests {
author: alice_id, content: "x".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1589,7 +1624,7 @@ mod tests {
fn dummy_post(g: Option<crate::types::FoFCommentGating>) -> crate::types::Post {
crate::types::Post {
author: [0u8; 32], content: String::new(), attachments: vec![],
timestamp_ms: 0, fof_gating: g, supersedes_post_id: None,
timestamp_ms: 0, fof_gating: g, supersedes_post_id: None, comment_ttl: None,
}
}
@ -1694,6 +1729,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
s.store_post_with_intent(
&post_id, &post,
@ -1825,6 +1861,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Bob's first scan — full scan path, populates cache.
@ -1882,6 +1919,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Persist the post so the sweep can re-fetch it.
s.store_post_with_intent(
@ -1957,6 +1995,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
.expect("Bob can unlock");
@ -2098,7 +2137,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)))
let built = build_fof_comment_gating(&s, &alice_id, Some(OpenSlotSpec::new(OpenSlotKind::Greeting, 1024)))
.unwrap().expect("built");
let decl = built.gating.open_slot.as_ref().expect("open slot declared");
assert_eq!(decl.kind, OpenSlotKind::Greeting);
@ -2110,6 +2149,7 @@ mod tests {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
comment_ttl: None,
};
// Wire-shape validation passes.
@ -2143,27 +2183,39 @@ mod tests {
// slot_index out of bounds.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: None });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of bounds"), "got: {}", err);
// body_bucket too big.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 });
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097, max_comments: None });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of range"), "got: {}", err);
// body_bucket zero.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 });
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0, max_comments: None });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes.
// v0.8: max_comments = 0 is malformed — refusal is declaring no
// open slot at all, not an unreachable one.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: Some(0) });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes, with and without an author LIMIT.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: None });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024, max_comments: Some(5) });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
}

View file

@ -57,6 +57,7 @@ pub fn build_distribution_post(
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let post_id = compute_post_id(&post);
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
@ -239,6 +240,7 @@ mod tests {
timestamp_ms: 200,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

View file

@ -291,6 +291,13 @@ pub async fn import_as_personas(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Preserve the original visibility intent from the export.
@ -466,6 +473,13 @@ pub async fn import_public_posts(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Read blob data from archive
@ -704,6 +718,13 @@ pub async fn merge_with_key(
timestamp_ms: ep.timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
// v0.8: inside `PostId = BLAKE3(Post)` — dropping it on
// import fails `verify_post_id` for any post that
// declares a comment-retention policy.
comment_ttl: ep
.comment_ttl_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
};
// Read blob data from archive (may need decryption for encrypted posts)

View file

@ -2,6 +2,7 @@ pub mod activity;
#[cfg(target_os = "android")]
pub mod android_wifi;
pub mod blob;
pub mod comment_ttl;
pub mod connection;
pub mod content;
pub mod control;

View file

@ -2417,6 +2417,7 @@ mod tests {
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}

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,
};

View file

@ -208,6 +208,7 @@ pub fn build_profile_post(
timestamp_ms,
fof_gating,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -478,6 +479,7 @@ mod tests {
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
// Apply. Auto-scan should fire and store the unwrapped V_me.
@ -548,6 +550,7 @@ mod tests {
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();

View file

@ -48,30 +48,19 @@ pub const MAX_REGISTRY_NAME_CHARS: usize = 64;
pub const MAX_REGISTRY_KEYWORDS: usize = 8;
pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32;
/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE
/// signing (the expiry is inside the signed digest).
pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// The registry post's comment-retention policy: a FLAT 30 days for
/// EVERY comment on it — registrations, duplicate-reports, anything.
/// It is carried in the post's own `comment_ttl` field, i.e. through the
/// same generic mechanism a future author-set TTL will use; nothing
/// downstream branches on "is this the registry post?".
pub const REGISTRY_COMMENT_TTL_POLICY: crate::types::CommentTtlPolicy =
crate::types::CommentTtlPolicy::flat(REGISTRATION_TTL_MS);
/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`.
/// The randomness serves identity hygiene — throwaway commenter IDs
/// vanish at unpredictable times.
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` makes every ordinary
/// comment expire in n seconds (integration-test hook; debug builds only).
pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 {
#[cfg(debug_assertions)]
{
if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") {
if let Ok(secs) = secs.parse::<u64>() {
return now_ms + secs * 1000;
}
}
}
use rand::Rng;
let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS);
now_ms + ttl
}
/// Open-slot stranger-channel TTL window: rand(30..=365 days).
/// Retained as aliases; the definitions live in [`crate::comment_ttl`].
pub use crate::comment_ttl::{
OPEN_SLOT_TTL_MAX_MS as ORDINARY_TTL_MAX_MS, OPEN_SLOT_TTL_MIN_MS as ORDINARY_TTL_MIN_MS,
};
/// The frozen canonical serialized bytes of the registry post. Generated
/// once (see `build_canonical_registry_post` + the `frozen_bytes_*`
@ -81,10 +70,10 @@ pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json");
/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted
/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`).
pub const REGISTRY_POST_ID: PostId = [
0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc,
0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d,
0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf,
0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4,
0x10, 0xa1, 0xbe, 0x33, 0x83, 0xef, 0xb2, 0x97,
0x76, 0x07, 0xfe, 0x45, 0xc4, 0xa7, 0xb3, 0xf1,
0xb5, 0xe6, 0x26, 0xe8, 0x1d, 0x0a, 0xc1, 0xaf,
0x9c, 0x0f, 0x3d, 0x7e, 0xb9, 0x86, 0x4d, 0x32,
];
/// Deterministic builder for the canonical registry post. All seal
@ -122,6 +111,9 @@ pub fn build_canonical_registry_post() -> Result<Post> {
slot_index: 0,
kind: OpenSlotKind::Registry,
body_bucket: REGISTRY_BODY_BUCKET,
// Registry chains are bounded by the 10MB blob split, not a
// count cap (design: family auto-split).
max_comments: None,
}),
};
@ -138,6 +130,10 @@ pub fn build_canonical_registry_post() -> Result<Post> {
timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS,
fof_gating: Some(gating),
supersedes_post_id: None,
// The 30-day retention for EVERY comment on this post rides the
// generic per-post policy field — no registry special case
// anywhere downstream.
comment_ttl: Some(REGISTRY_COMMENT_TTL_POLICY),
})
}
@ -198,9 +194,26 @@ pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
/// Fixed-30d TTL check for registrations: `expires_at_ms timestamp_ms`
/// must equal 30 days within ±5 minutes of slack.
///
/// This is now just the registry specialization of the GENERIC
/// [`crate::comment_ttl::ttl_ok`] — the ingest gate calls the generic
/// form against whatever policy the parent post declares, so registry
/// comments that are not registrations are covered by the same rule.
///
/// The `ITSGOIN_TEST_TTL_SECS` debug override does NOT apply here: it is
/// scoped to randomized windows (`min < max`, i.e. the open-slot
/// greeting channel), and the registration window is flat. A flat policy
/// is a correctness constraint holders enforce exactly, not a duration
/// knob — so this bound holds in debug builds too.
pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool {
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; };
ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS
crate::comment_ttl::ttl_ok(
crate::comment_ttl::CommentTtlRule::Window {
min_ttl_ms: REGISTRATION_TTL_MS,
max_ttl_ms: REGISTRATION_TTL_MS,
},
timestamp_ms,
expires_at_ms,
)
}
/// One search hit from the local registry chain.
@ -322,6 +335,12 @@ mod tests {
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
assert_eq!(gating.wrap_slots.len(), 1);
// v0.8: the flat-30d retention for EVERY comment on this post is
// part of the canonical bytes, carried in the generic per-post
// policy field.
assert_eq!(post.comment_ttl, Some(REGISTRY_COMMENT_TTL_POLICY));
assert_eq!(post.comment_ttl.unwrap().min_ttl_ms, REGISTRATION_TTL_MS);
assert_eq!(post.comment_ttl.unwrap().max_ttl_ms, REGISTRATION_TTL_MS);
}
/// The registry open slot must actually open under the derivable key
@ -472,3 +491,4 @@ mod tests {
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
}
}

View file

@ -1 +1 @@
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}}
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}},"comment_ttl":{"min_ttl_ms":2592000000,"max_ttl_ms":2592000000}}

View file

@ -50,6 +50,26 @@ pub struct Storage {
conn: Connection,
}
/// The SELECT list every `Post`-reconstructing query MUST use, in this
/// order (see [`Storage::parse_post_row`]).
///
/// A `PostId` is `BLAKE3(Post)` — the post IS its fields. A query that
/// omits one hands back a struct whose id no longer verifies, and every
/// receiver (`content::verify_post_id` on the sync, pull and import
/// paths) then discards the post as a forged signature, with no
/// diagnostic. That is exactly how `fof_gating` posts — and, once
/// authors can set one, any post carrying a `comment_ttl` retention
/// policy — became unsyncable and unexportable.
const POST_COLUMNS: &str =
"id, author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json";
/// [`POST_COLUMNS`] qualified for queries that alias `posts` as `p`.
const POST_COLUMNS_P: &str =
"p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility, p.fof_gating_json, p.comment_ttl_json";
/// One raw row in [`POST_COLUMNS`] order.
type PostRow = (Vec<u8>, Vec<u8>, String, String, i64, String, Option<String>, Option<String>);
/// Pool of Storage connections for concurrent SQLite access in WAL mode.
/// Each connection is independently locked — readers don't block each other.
/// Uses tokio::sync::Mutex so guards are Send (safe across .await points).
@ -965,9 +985,36 @@ impl Storage {
)?;
}
// v0.8: post.comment_ttl — the per-post comment retention policy
// (`crate::comment_ttl`). MUST be persisted: holders enforce
// incoming comments' TTLs against the parent post they read back
// out of this table, so dropping the column on the floor would
// silently degrade every TTL'd post to the class defaults.
let has_comment_ttl = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name='comment_ttl_json'"
)?.query_row([], |row| row.get::<_, i64>(0))?;
if has_comment_ttl == 0 {
self.conn.execute_batch(
"ALTER TABLE posts ADD COLUMN comment_ttl_json TEXT;"
)?;
}
Ok(())
}
/// Serialize `post.comment_ttl` for the `comment_ttl_json` column.
fn comment_ttl_json(post: &Post) -> anyhow::Result<Option<String>> {
Ok(match &post.comment_ttl {
Some(p) => Some(serde_json::to_string(p)?),
None => None,
})
}
/// Parse a `comment_ttl_json` column value back into a policy.
fn parse_comment_ttl(raw: Option<String>) -> Option<crate::types::CommentTtlPolicy> {
raw.and_then(|s| serde_json::from_str(&s).ok())
}
// ---- Posts ----
/// Store a post with default Public visibility. Returns true if it was new.
@ -989,8 +1036,8 @@ impl Storage {
None => None,
};
let inserted = self.conn.execute(
"INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id.as_slice(),
post.author.as_slice(),
@ -999,6 +1046,7 @@ impl Storage {
post.timestamp_ms as i64,
visibility_json,
fof_json,
Self::comment_ttl_json(post)?,
],
)?;
if inserted > 0 {
@ -1009,7 +1057,7 @@ impl Storage {
pub fn get_post(&self, id: &PostId) -> anyhow::Result<Option<Post>> {
let mut stmt = self.conn.prepare(
"SELECT author, content, attachments, timestamp_ms, fof_gating_json
"SELECT author, content, attachments, timestamp_ms, fof_gating_json, comment_ttl_json
FROM posts WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id.as_slice()])?;
@ -1020,6 +1068,7 @@ impl Storage {
let fof_json: Option<String> = row.get(4)?;
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
let comment_ttl = Self::parse_comment_ttl(row.get(5)?);
Ok(Some(Post {
author: blob_to_nodeid(row.get(0)?)?,
content: row.get(1)?,
@ -1035,6 +1084,7 @@ impl Storage {
// dedicated column if/when receivers need to render the
// supersedes pointer in feeds.
supersedes_post_id: None,
comment_ttl,
}))
} else {
Ok(None)
@ -1046,7 +1096,7 @@ impl Storage {
id: &PostId,
) -> anyhow::Result<Option<(Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json
"SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json, comment_ttl_json
FROM posts WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id.as_slice()])?;
@ -1059,6 +1109,7 @@ impl Storage {
let fof_json: Option<String> = row.get(5)?;
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
let comment_ttl = Self::parse_comment_ttl(row.get(6)?);
Ok(Some((
Post {
author: blob_to_nodeid(row.get(0)?)?,
@ -1067,6 +1118,7 @@ impl Storage {
timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating,
supersedes_post_id: None,
comment_ttl,
},
visibility,
)))
@ -1133,99 +1185,49 @@ impl Storage {
/// All posts, newest first (with visibility)
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
/// Feed: posts from followed users, reverse chronological (with visibility)
pub fn get_feed(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS_P}
FROM posts p
INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
/// Feed: paginated — posts from followed users, cursor-based by timestamp
pub fn get_feed_page(&self, before_ms: Option<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let sql = if before_ms.is_some() {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
format!(
"SELECT {POST_COLUMNS_P}
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE p.timestamp_ms < ?1
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC LIMIT ?2"
ORDER BY p.timestamp_ms DESC LIMIT ?2")
} else {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
format!(
"SELECT {POST_COLUMNS_P}
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
AND p.author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY p.timestamp_ms DESC LIMIT ?2"
ORDER BY p.timestamp_ms DESC LIMIT ?2")
};
let mut stmt = self.conn.prepare(sql)?;
let mut stmt = self.conn.prepare(&sql)?;
let rows = if let Some(bms) = before_ms {
stmt.query_map(rusqlite::params![bms as i64, limit as i64], Self::parse_post_row)?
} else {
@ -1237,20 +1239,22 @@ impl Storage {
/// All posts: paginated — cursor-based by timestamp
pub fn list_posts_page(&self, before_ms: Option<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let sql = if before_ms.is_some() {
"SELECT id, author, content, attachments, timestamp_ms, visibility
format!(
"SELECT {POST_COLUMNS}
FROM posts
WHERE timestamp_ms < ?1
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC LIMIT ?2"
ORDER BY timestamp_ms DESC LIMIT ?2")
} else {
"SELECT id, author, content, attachments, timestamp_ms, visibility
format!(
"SELECT {POST_COLUMNS}
FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
AND author NOT IN (SELECT node_id FROM ignored_peers)
ORDER BY timestamp_ms DESC LIMIT ?2"
ORDER BY timestamp_ms DESC LIMIT ?2")
};
let mut stmt = self.conn.prepare(sql)?;
let mut stmt = self.conn.prepare(&sql)?;
let rows = if let Some(bms) = before_ms {
stmt.query_map(rusqlite::params![bms as i64, limit as i64], Self::parse_post_row)?
} else {
@ -1354,30 +1358,51 @@ impl Storage {
Ok(result)
}
/// Helper: parse a post row from a query
fn parse_post_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Vec<u8>, Vec<u8>, String, String, i64, String)> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?))
/// Helper: parse a post row from a query. The SELECT list MUST be
/// [`POST_COLUMNS`] (or its `p.`-qualified twin) — see that constant
/// for why a missing column is a silent network-wide drop.
fn parse_post_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PostRow> {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
}
/// Helper: collect parsed post rows into typed results
fn collect_posts(rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<(Vec<u8>, Vec<u8>, String, String, i64, String)>>) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
/// Helper: one raw row → the content-addressed triple.
fn post_from_row(row: PostRow) -> anyhow::Result<(PostId, Post, PostVisibility)> {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json, fof_json, ttl_json) = row;
let attachments: Vec<Attachment> =
serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
Ok((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
fof_gating,
// Not persisted as a column today (see `get_post`).
supersedes_post_id: None,
comment_ttl: Self::parse_comment_ttl(ttl_json),
},
visibility,
));
))
}
/// Helper: collect parsed post rows into typed results
fn collect_posts(rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<PostRow>>) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut posts = Vec::new();
for row in rows {
posts.push(Self::post_from_row(row?)?);
}
Ok(posts)
}
@ -1386,37 +1411,11 @@ impl Storage {
/// Includes control/profile posts — they need to propagate through the
/// CDN like any other post.
pub fn list_posts_with_visibility(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
let id_bytes: Vec<u8> = row.get(0)?;
let author_bytes: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments_json: String = row.get(3)?;
let timestamp_ms: i64 = row.get(4)?;
let vis_json: String = row.get(5)?;
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
})?;
let mut posts = Vec::new();
for row in rows {
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
posts.push((
blob_to_postid(id_bytes)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: timestamp_ms as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts ORDER BY timestamp_ms DESC",
))?;
let rows = stmt.query_map([], Self::parse_post_row)?;
Self::collect_posts(rows)
}
// ---- Follows ----
@ -3119,8 +3118,8 @@ impl Storage {
};
let inserted = self.conn.execute(
"INSERT OR IGNORE INTO posts
(id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json, comment_ttl_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
id.as_slice(),
post.author.as_slice(),
@ -3130,6 +3129,7 @@ impl Storage {
visibility_json,
intent_json,
fof_json,
Self::comment_ttl_json(post)?,
],
)?;
if inserted > 0 {
@ -3190,30 +3190,14 @@ impl Storage {
// Use LIKE to find posts whose visibility_intent JSON contains the circle name.
// The serialized form is {"Circle":"name"} so we search for that pattern.
let pattern = format!("%\"Circle\":\"{}\"%", circle_name);
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts WHERE author = ?1 AND visibility_intent LIKE ?2",
let mut stmt = self.conn.prepare(&format!(
"SELECT {POST_COLUMNS} FROM posts WHERE author = ?1 AND visibility_intent LIKE ?2",
))?;
let rows = stmt.query_map(
params![our_node_id.as_slice(), pattern],
Self::parse_post_row,
)?;
let mut posts = Vec::new();
let mut rows = stmt.query(params![our_node_id.as_slice(), pattern])?;
while let Some(row) = rows.next()? {
let attachments: Vec<Attachment> =
serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or_default();
let visibility: PostVisibility =
serde_json::from_str(&row.get::<_, String>(5)?).unwrap_or_default();
posts.push((
blob_to_postid(row.get(0)?)?,
Post {
author: blob_to_nodeid(row.get(1)?)?,
content: row.get(2)?,
attachments,
timestamp_ms: row.get::<_, i64>(4)? as u64,
fof_gating: None,
supersedes_post_id: None,
},
visibility,
));
}
Ok(posts)
Self::collect_posts(rows)
}
// ---- Replica tracking ----
@ -6205,8 +6189,15 @@ impl Storage {
pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index),
group_sig = COALESCE(excluded.group_sig, group_sig),
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload),
expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0
THEN excluded.expires_at ELSE expires_at END",
-- v0.8: the expiry is INSIDE the signed comment digest, so
-- the incoming value is always author-committed including
-- NULL, which is how the `0` never-expires sentinel is
-- stored. Taking it unconditionally is what makes the
-- sentinel honoured by EVERY consumer: the old
-- `excluded.expires_at > 0` guard (written when NULL meant
-- \"legacy, unknown\") would keep a stale TTL on a comment
-- its author republished as permanent.
expires_at = excluded.expires_at",
params![
comment.author.as_slice(),
comment.post_id.as_slice(),
@ -6592,7 +6583,7 @@ impl Storage {
author: &NodeId,
) -> anyhow::Result<Vec<(PostId, Post)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json, comment_ttl_json
FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL
ORDER BY timestamp_ms DESC",
)?;
@ -6603,11 +6594,12 @@ impl Storage {
let attachments: String = row.get(3)?;
let ts: i64 = row.get(4)?;
let fof_json: Option<String> = row.get(5)?;
Ok((id, author, content, attachments, ts, fof_json))
let ttl_json: Option<String> = row.get(6)?;
Ok((id, author, content, attachments, ts, fof_json, ttl_json))
})?;
let mut result = Vec::new();
for row in rows {
let (id, author_bytes, content, attachments_json, ts, fof_json) = row?;
let (id, author_bytes, content, attachments_json, ts, fof_json, ttl_json) = row?;
let attachments: Vec<Attachment> =
serde_json::from_str(&attachments_json).unwrap_or_default();
let fof_gating = fof_json
@ -6621,6 +6613,7 @@ impl Storage {
timestamp_ms: ts as u64,
fof_gating,
supersedes_post_id: None,
comment_ttl: Self::parse_comment_ttl(ttl_json),
},
));
}
@ -7634,6 +7627,7 @@ mod tests {
timestamp_ms: ts,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
};
let id = blake3::hash(&serde_json::to_vec(&post).unwrap());
s.store_post(id.as_bytes(), &post).unwrap();
@ -8264,6 +8258,7 @@ mod tests {
timestamp_ms: ts,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: None,
}
}
@ -8452,6 +8447,85 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// v0.8: `Post.comment_ttl` MUST survive the storage round-trip.
/// Holders enforce incoming comments against the parent post they
/// read back out of this table — if the policy were dropped here,
/// every TTL'd post would silently fall back to the class defaults
/// and the registry's flat 30d would stop being enforced.
#[test]
fn comment_ttl_policy_round_trips() {
let s = temp_storage();
let policy = crate::types::CommentTtlPolicy::flat(7 * 24 * 3600 * 1000);
let post = Post {
author: make_node_id(1),
content: "ttl'd".into(),
attachments: vec![],
timestamp_ms: 1000,
fof_gating: None,
supersedes_post_id: None,
comment_ttl: Some(policy),
};
let id = crate::content::compute_post_id(&post);
s.store_post(&id, &post).unwrap();
assert_eq!(s.get_post(&id).unwrap().unwrap().comment_ttl, Some(policy));
assert_eq!(
s.get_post_with_visibility(&id).unwrap().unwrap().0.comment_ttl,
Some(policy),
);
// No policy stays no policy (NULL column → None, not a default).
let plain = Post { comment_ttl: None, content: "plain".into(), ..post.clone() };
let plain_id = crate::content::compute_post_id(&plain);
s.store_post(&plain_id, &plain).unwrap();
assert_eq!(s.get_post(&plain_id).unwrap().unwrap().comment_ttl, None);
// …and through EVERY list query. `PostId = BLAKE3(Post)`, so a
// query that drops the policy yields a post whose id no longer
// verifies — the ContentSync responder builds its `SyncPost`s
// from `list_posts_with_visibility` and every receiver runs
// `verify_post_id`, so a dropped field is a silent network-wide
// discard, not a cosmetic omission.
let found = |list: Vec<(PostId, Post, PostVisibility)>| -> Post {
list.into_iter().find(|(pid, _, _)| *pid == id).expect("post listed").1
};
for (label, post) in [
("list_posts_with_visibility", found(s.list_posts_with_visibility().unwrap())),
("list_posts_reverse_chron", found(s.list_posts_reverse_chron().unwrap())),
("list_posts_page", found(s.list_posts_page(None, 100).unwrap())),
] {
assert_eq!(post.comment_ttl, Some(policy), "{label} kept the retention policy");
assert!(
crate::content::verify_post_id(&id, &post),
"{label} must round-trip to a post whose id still verifies",
);
}
}
/// v0.8: `expires_at_ms == 0` is the never-expires SENTINEL, inside
/// the signed digest — so re-storing a comment as permanent must
/// clear a previously stored TTL (the upsert used to refuse it).
#[test]
fn never_expires_sentinel_clears_a_stored_ttl() {
let s = temp_storage();
let post_id = make_post_id(9);
let author = make_node_id(9);
let now = test_now_ms();
s.store_comment(&ttl_comment(author, post_id, 1000, now + 60_000)).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap()[0].expires_at_ms, now + 60_000);
// Same (author, post_id, timestamp_ms) key, republished with the
// sentinel.
s.store_comment(&ttl_comment(author, post_id, 1000, 0)).unwrap();
assert_eq!(
s.get_comments(&post_id).unwrap()[0].expires_at_ms, 0,
"the sentinel must overwrite a stored TTL",
);
assert_eq!(
s.expire_comments(now + 10 * 24 * 3600 * 1000).unwrap(), 0,
"…and the row must then survive the sweep",
);
}
/// expire_comments hard-deletes only due rows; legacy (NULL/0) rows
/// are never touched.
#[test]
@ -8537,3 +8611,4 @@ mod tests {
assert_eq!(keys[0].1, post_id);
}
}

View file

@ -55,6 +55,46 @@ pub struct Post {
/// 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>,
/// v0.8: author-declared retention policy for comments on THIS post.
/// `None` = no author policy; retention then falls back to the
/// per-signing-class default taxonomy (see [`crate::comment_ttl`]).
/// Covered by `PostId = BLAKE3(Post)`, so it is immutable per publish
/// and independently verifiable — which is what lets a holder that
/// never met the author ENFORCE it against incoming comments.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment_ttl: Option<CommentTtlPolicy>,
}
/// v0.8: a per-post comment retention window. Deliberately GENERIC: the
/// registry post populates it with a flat 30 days, and a future release
/// can surface it in the composer so any author can set a retention
/// window on their own post's comments — same field, same holder-side
/// enforcement, no redesign.
///
/// `min_ttl_ms == max_ttl_ms` is a FLAT TTL (the registry case: every
/// comment must expire exactly 30 days after its own timestamp, ±clock
/// slack). `min < max` is a randomized window (the shape the open-slot
/// greeting channel uses by default).
///
/// A policy applies to EVERY comment on the post regardless of signing
/// class — that is how "all comments on a registry post expire at 30d",
/// not just registrations, falls out without an `is_registry_post`
/// special case.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommentTtlPolicy {
/// Shortest accepted TTL, in ms after the comment's own timestamp.
pub min_ttl_ms: u64,
/// Longest accepted TTL. Holders additionally clamp this to
/// `comment_ttl::MAX_COMMENT_TTL_MS`.
pub max_ttl_ms: u64,
}
impl CommentTtlPolicy {
/// A flat TTL — every comment must expire exactly `ttl_ms` after its
/// own timestamp.
pub const fn flat(ttl_ms: u64) -> Self {
Self { min_ttl_ms: ttl_ms, max_ttl_ms: ttl_ms }
}
}
/// A reference to a media blob attached to a post
@ -1228,6 +1268,16 @@ pub struct OpenSlotDecl {
pub kind: OpenSlotKind,
/// Padded plaintext bucket size in bytes (single bucket, design §27).
pub body_bucket: u16,
/// v0.8: author-declared LIMIT on how many live comments this open
/// slot will accept. `None` = the holder's own default ceiling for
/// the slot kind. Authors may only LOWER the ceiling, never raise it
/// (holders clamp) — the cap protects holders, so a post can't buy
/// itself more of someone else's storage.
///
/// Refusal is already expressible structurally: a post that declares
/// no open slot accepts no such comments at all.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_comments: Option<u32>,
}
/// FoF Layer 2: the author-published gating block embedded in a

View file

@ -12,7 +12,13 @@
# unsealed text + real sender persona; node2 replies; node3 sees the
# reply. node1 (holder) stores only ciphertext.
# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry.
# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep.
# 6. Retention taxonomy (per-post policy x signing class): every comment
# on the registry post carries the post's FLAT 30d policy — including
# on holders that never registered — while greetings on a bio's open
# slot carry the randomized 30-365d stranger TTL. Asserted against
# each node's DB, so it also covers the debug ITSGOIN_TEST_TTL_SECS
# hook staying scoped to randomized windows (a flat policy must keep
# its exact 30 days, or the registry auto-renew loop never settles).
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log
set -u
@ -137,6 +143,38 @@ check "node3 sees exactly one (newest) entry for node2's persona" \
bash -c 'quiet_tail /tmp/itsgoin-cli3.log 6 | grep -c "rust" | grep -q "^1$" &&
quiet_tail /tmp/itsgoin-cli3.log 6 | grep -q AliceV2'
echo "== step 6: retention taxonomy =="
# (parent post's comment-TTL policy) x (comment's signing class).
DAY_MS=86400000
FLAT30=$((30 * DAY_MS))
YEAR365=$((365 * DAY_MS))
reg_hex=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}' | tr 'a-z' 'A-Z')
bio2_hex=$(echo "$bio2" | tr 'a-z' 'A-Z')
reg_total=0; reg_bad=0
for i in 1 2 3; do
reg_total=$((reg_total + $(sq $i "SELECT count(*) FROM comments WHERE hex(post_id)='$reg_hex'")))
reg_bad=$((reg_bad + $(sq $i "SELECT count(*) FROM comments WHERE hex(post_id)='$reg_hex'
AND (expires_at IS NULL OR expires_at - timestamp_ms != $FLAT30)")))
done
check "every registry-post comment carries the post's flat 30d policy" \
bash -c "[ $reg_total -gt 0 ] && [ $reg_bad -eq 0 ]"
greet_total=$(sq 2 "SELECT count(*) FROM comments WHERE hex(post_id)='$bio2_hex'")
greet_ok=$(sq 2 "SELECT count(*) FROM comments WHERE hex(post_id)='$bio2_hex'
AND expires_at IS NOT NULL
AND expires_at - timestamp_ms BETWEEN $FLAT30 AND $YEAR365")
check "greetings on the bio open slot carry the randomized 30-365d TTL" \
bash -c "[ $greet_total -gt 0 ] && [ $greet_ok -eq $greet_total ]"
over_cap=0
for i in 1 2 3; do
over_cap=$((over_cap + $(sq $i "SELECT count(*) FROM comments
WHERE expires_at IS NOT NULL AND expires_at - timestamp_ms > $((366 * DAY_MS))")))
done
check "no stored comment claims a TTL past the 366d holder ceiling" \
bash -c "[ $over_cap -eq 0 ]"
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

View file

@ -1393,13 +1393,24 @@
<li>Remotely: no notice is sent. Copies held by other <code>file_holders</code> become <em>orphans</em> &mdash; nothing references them, nothing re-shares them &mdash; and are evicted naturally by the CDN's LRU eviction cycle (see <a href="#content">Content Propagation</a>).</li>
</ol>
<h3>Comment expiry: rand(30&ndash;365 days) TTL <span class="badge badge-planned">Planned</span></h3>
<p>Every comment gets an expiry timestamp at creation: <code>created_at + rand(30&ndash;365 days)</code>. The expiry is <strong>fixed at creation, signed, and carried with the comment</strong> &mdash; it is part of the comment's identity, not a per-holder policy. Every holder in the network expires the comment at (near) the same moment.</p>
<h3>Comment retention: (post policy) &times; (signing class) <span class="badge badge-complete">Implemented</span></h3>
<p>A comment's expiry is <strong>fixed at creation, signed, and carried with the comment</strong> (it is inside the comment digest, so it is part of the comment's identity, not a per-holder policy) &mdash; but <em>most comments never expire at all</em>. Which rule applies is decided by two inputs a holder can evaluate on its own: the <strong>parent post's comment-TTL policy</strong> and the <strong>comment's signing class</strong>.</p>
<table>
<tr><th>Parent post policy</th><th>Signing class</th><th>Retention</th></tr>
<tr><td>none</td><td>Public comment (no slot claim)</td><td><strong>Never expires</strong></td></tr>
<tr><td>none</td><td>Post-key-signed (the authorized/FoF commenter channel)</td><td><strong>Never expires</strong></td></tr>
<tr><td>none</td><td>Open slot &mdash; private, <em>not</em> post-key-signed (stranger greetings)</td><td><code>rand(30&ndash;365 days)</code></td></tr>
<tr><td><strong>declared</strong></td><td>any class</td><td>The post's window, for every comment on it</td></tr>
</table>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong><code>0</code> = never, and it is signed</strong>: <code>expires_at_ms == 0</code> is the never-expires sentinel. It rides inside the signed digest, so the author commits to it and holders enforce it &mdash; the expiry sweep, the ingest gate and the UI all read the same value.</li>
<li><strong>Only the stranger channel auto-expires</strong>: the randomized draw is the <em>throwaway-ID retirement mechanism</em>, so it is scoped to exactly the channel that mints throwaway IDs. Public and post-key-signed comments are ordinary content and are kept like ordinary content.</li>
<li><strong>Why random, there</strong>: a fixed TTL would let observers date a greeting's creation from its expiry. A 30&ndash;365-day uniform draw breaks that inference. (A <em>deliberately public</em> policy like the registry's does not want that anonymity and uses a flat window instead.)</li>
<li><strong>Why carried, not local</strong>: if each holder chose its own retention, staggered disappearance would fingerprint individual holders and their policies. A carried timestamp makes expiry a network-wide event that reveals nothing about who holds what.</li>
<li><strong>Why random</strong>: a fixed TTL would let observers date a comment's creation from its expiry. A 30&ndash;365-day uniform draw breaks that inference.</li>
<li><strong>Identity hygiene</strong>: throwaway persona IDs used for greeting comments (<a href="#discovery">Discovery &amp; First Contact</a>) exist in the network only through their comments. When the comments expire, the IDs vanish from uniques lists entirely &mdash; the network forgets them by construction.</li>
<li><strong>No silent extension</strong>: the TTL cannot be edited &mdash; re-posting the content is a new comment with a new ID and a fresh draw.</li>
<li><strong>The policy is generic, not a registry special case</strong>: <code>Post.comment_ttl</code> is a plain post field. The registry post (<a href="#discovery">Discovery &amp; First Contact</a>) populates it with a flat 30 days &mdash; the same mechanism a future author-set &ldquo;comments on this post expire in N days&rdquo; will use. No authoring UI ships yet; the field, the wire format and the holder-side enforcement do.</li>
<li><strong>Holders enforce, in both directions</strong>: the TTL is self-asserted, so every ingest path re-derives the rule from the parent post and drops comments that contradict it &mdash; a never-expiring comment on a TTL'd post (a permanent registry squat) <em>and</em> an over-long TTL on the stranger channel (a permanent squat on someone's greeting budget). A holder that does not hold the parent post has not seen its policy, so it accepts a bounded TTL (which self-heals) but never grants permanence. Absolute ceiling on any accepted TTL: 366 days.</li>
<li><strong>No silent extension</strong>: the TTL cannot be edited &mdash; re-posting the content is a new comment with a new signature and a fresh draw.</li>
<li><strong>Consequence</strong>: permanent comment classes mean comment chains grow monotonically, so the 10 MB blob split is the only real bound &mdash; which raises the value of range-tree reconciliation over a flat op-ID exchange.</li>
</ul>
</section>
@ -1432,7 +1443,7 @@
<li><strong>Jittered, tiered update cadence</strong>: CDN update checks run on a descending-frequency schedule (minutes &rarr; days) keyed on freshness &times; relationship tier, with jitter on every timer (see <a href="#keep-alive">Update Cadence</a>). No check fires <em>because</em> a post appeared; checks fire because a schedule slot came up.</li>
<li><strong>Replication shaped as overhead</strong>: replication pushes are paced and jittered so an author seeding their own content is indistinguishable from a holder performing routine redundancy maintenance. The goal: update + replication traffic reads as uniform network overhead, with no observable "origin burst".</li>
<li><strong>2&ndash;5 visible posts per author</strong>: the public CDN surface reveals at most 2&ndash;5 posts per author at any time; the surplus is pushed out to holders via replication requests and reached through the uniques index. This caps what enumeration of any single node's public holdings discloses about any author's corpus &mdash; and hides the author's own node behind ordinary public mesh IDs.</li>
<li><strong>Comment TTL</strong>: long-lived comment trails are the easiest correlation anchors across time; the 30&ndash;365-day expiry bounds how long any trail exists.</li>
<li><strong>Open-slot comment TTL</strong>: long-lived comment trails are the easiest correlation anchors across time. The 30&ndash;365-day expiry bounds how long a <em>throwaway-ID</em> trail exists &mdash; it applies to the open-slot stranger channel (greetings), which is where the anonymity claim lives. Public and post-key-signed comments do not expire; they are attributable content, and their trail is bounded by nothing but deletion.</li>
</ul>
<div class="note">
<strong>Honest scope</strong>: this defeats passive observers correlating timing and public holdings. A global adversary who can watch every link, or an adversary who compromises your own device, is out of scope &mdash; as in every friend-to-friend design.
@ -1694,7 +1705,7 @@
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>The post</strong>: published at network genesis by the default anchor's persona; its ID is derived from a well-known seed the app ships with (<code>shard(seed, k)</code> &mdash; shard count starts at 1, and the derivation scheme exists from day one so splitting by <code>hash(persona_id)</code> later is a threshold change, not a migration). It replicates as ordinary CDN content; anchors hold it by durability, not appointment.</li>
<li><strong>The entry</strong>: a registration comment carries {display name, search keywords, persona ID / public author ID} <strong>signed by the registering persona's posting key</strong> &mdash; self-certifying: any holder can verify the entry against the key it names, no authority consulted.</li>
<li><strong>Self-cleaning</strong>: registration comments carry a <strong>fixed 30-day TTL</strong> (unlike ordinary comments' randomized TTL &mdash; that randomness serves anonymity, which a deliberately-public registration doesn't want). Staying listed means re-signing a fresh entry; holders keep <strong>one active entry per persona ID</strong> (newest wins). The chain self-cleans and is never more than 30 days stale.</li>
<li><strong>Self-cleaning</strong>: the registry post <strong>declares a flat 30-day comment-TTL policy</strong>, and that policy binds <em>every</em> comment on the post &mdash; registrations, duplicate-reports, anything &mdash; not just the ones that parse as entries. (Flat rather than randomized: the randomness serves anonymity, which a deliberately-public registration doesn't want, and a flat window lets clients simply assume &ldquo;renew before 30 days&rdquo; with no per-entry expiry tracking.) It is the generic per-post policy of <a href="#deletes">Comment retention</a>, not a registry special case. Staying listed means re-signing a fresh entry; holders keep <strong>one active entry per persona ID</strong> (newest wins). The chain self-cleans and is never more than 30 days stale.</li>
<li><strong>Delete / modify</strong>: a delete request signed by the same persona key is honored by every holder (verifiable from the entry itself &mdash; the network agreement is checkable, not honor-system). Modify = delete + re-register; no separate primitive needed.</li>
<li><strong>Moderation hook</strong>: the registry post's author retains the standard per-post <code>RevocationEntry</code> power &mdash; manual spam removal for the alpha era, at the acknowledged cost that the author <em>could</em> censor entries. Acceptable while the author is the project's own anchor; the trust layer (see <a href="#directory">Directory Trust Layer</a>) is the eventual replacement.</li>
<li><strong>Flood limits</strong>: registrations fit a single small size bucket and are rate-capped per holder. <em>Proof-of-work was considered and rejected</em>: its cost lands inverted (a phone registrant pays seconds of battery; a botnet rig pays effectively nothing), so it taxes the users we want while barely inconveniencing the adversary we fear. The registry's real defenses are the bounded blast radius and vouch gating below.</li>
@ -1709,7 +1720,7 @@
<li><strong>Sealed content</strong>: the greeting body is HPKE-sealed to the bio author's posting key. Inside: the sender's <em>real</em> persona ID and a return path. Outside: an opaque ciphertext authored by a <strong>throwaway outer ID</strong>. Observers learn that the bio received <em>a</em> greeting, not from whom.</li>
<li><strong>Blends in</strong>: FoF Mode&nbsp;2 already makes encrypted comment blobs commonplace on public posts. Greetings are one more ciphertext among many &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Off-switch</strong>: the author revokes the greeting <code>pub_x</code> via the existing per-post <code>RevocationEntry</code> sweep &mdash; propagation nodes delete stored greetings and stop accepting new ones. No new revocation machinery.</li>
<li><strong>Retirement</strong>: comments expire at a random TTL fixed at creation (see <a href="#deletes">Delete Propagation &amp; Comment Expiry</a>), so throwaway greeting IDs eventually vanish from the network &mdash; and from everyone's uniques lists &mdash; entirely.</li>
<li><strong>Retirement</strong>: open-slot comments &mdash; the one class that auto-expires &mdash; carry a random TTL fixed at creation (see <a href="#deletes">Comment retention</a>), so throwaway greeting IDs eventually vanish from the network, and from everyone's uniques lists, entirely.</li>
</ul>
<h3>Spam controls</h3>
@ -1717,7 +1728,8 @@
<tr><th>Control</th><th>Mechanism</th></tr>
<tr><td>Active choice</td><td>No greeting slot in the bio &rarr; no greetings possible. The choice is made visibly at first profile publish &mdash; pre-checked YES with opt-out before publishing (alpha default; revisit for public release). Revocable per persona at any time.</td></tr>
<tr><td>Size bucket</td><td>Greetings fit a single small padded bucket &mdash; no attachments, no long-form spam payloads.</td></tr>
<tr><td>Holder-side count caps</td><td>Nodes holding a bio cap how many unexpired greetings they store and forward per bio.</td></tr>
<tr><td>Holder-side count caps</td><td>Nodes holding a bio cap how many unexpired greetings they store and forward per bio (default 64).</td></tr>
<tr><td>Author-declared limit</td><td>The bio's open-slot declaration carries an optional <code>max_comments</code> &mdash; &ldquo;accept at most N live stranger comments on this post&rdquo; &mdash; signed into the post and enforced by every holder. Authors may <em>lower</em> the holder default, never raise it (the storage being spent is the holder's). This is the <em>limit</em> half of the per-post control; <em>refusal</em> is the structural half above: declare no open slot and no such comment is possible. Generic to open slots, not greeting-specific.</td></tr>
<tr><td>Rate caps</td><td>Holder-side per-bio limits on unexpired greetings stored and forwarded. (Proof-of-work stamps were considered and rejected &mdash; inverted cost: phones pay, botnets don't.)</td></tr>
</table>
<div class="note">
@ -1949,8 +1961,11 @@
<!-- Ruling: project_v08_mesh_redesign.md round 4 — per-disconnect stochastic action replaces threshold+jitter -->
<tr><td>CONVECTION_ACTION</td><td>stochastic, per-disconnect</td><td><span class="badge badge-complete">Implemented</span> On each mesh disconnect: random {nothing | anchor introduction | mesh-peer introduction}; weights adaptive = local anchor-density prior + refusal feedback</td></tr>
<tr><td>CONVECTION_CLASS</td><td>entry / top-up (1 bit)</td><td><span class="badge badge-complete">Implemented</span> Entry (&lt;2 connections) always served; top-up refused cheaply under load &mdash; refusal feeds the adaptive weights</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — comment TTL rand(30365d) fixed at creation -->
<tr><td>COMMENT_TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Comment expiry, fixed at creation and carried with the comment</td></tr>
<!-- Ruling 2026-08-03: retention = (post policy) × (signing class); comment_ttl.rs -->
<tr><td>COMMENT_TTL (public)</td><td>never expires (<code>0</code> sentinel)</td><td><span class="badge badge-complete">Implemented</span> Ordinary public comments are kept like ordinary content</td></tr>
<tr><td>COMMENT_TTL (post-key-signed)</td><td>never expires (<code>0</code> sentinel)</td><td><span class="badge badge-complete">Implemented</span> The authorized/FoF commenter channel</td></tr>
<tr><td>COMMENT_TTL (open slot)</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-complete">Implemented</span> The private, non-post-key-signed stranger channel &mdash; fixed at creation, carried with the comment; retires throwaway greeting IDs</td></tr>
<tr><td>COMMENT_TTL (per-post policy)</td><td>overrides every class</td><td><span class="badge badge-complete">Implemented</span> <code>Post.comment_ttl</code>; registry post = flat 30 days. Holder ceiling on any accepted TTL: 366 days</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — descending update cadence minutes→days -->
<tr><td>UPDATE_CADENCE</td><td>minutes &rarr; days</td><td><span class="badge badge-rework">Rework</span> Descending-frequency check scale keyed on freshness &times; relationship tier, jittered (seed: ENGAGEMENT_CHECK_TIERS above; see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>)</td></tr>
</table>
@ -1980,8 +1995,9 @@
<tr><td>File holders per content</td><td>5 (LRU)</td><td>Flat <code>file_holders</code> set; registration past 5 answers with a holder redirect</td></tr>
<!-- Ruling: publicly reveal only 25 posts per author -->
<tr><td>Publicly revealed posts per author</td><td>2&ndash;5</td><td><span class="badge badge-planned">Planned</span> Concealment budget; surplus pushed out via replication (see <a href="#privacy">Social Graph Privacy</a>)</td></tr>
<!-- Ruling: comment TTL rand(30365d) fixed at creation -->
<tr><td>Comment TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Fixed at creation, carried with the comment; retires throwaway greeting IDs</td></tr>
<!-- Ruling 2026-08-03: retention = (post policy) × (signing class); comment_ttl.rs -->
<tr><td>Comment TTL</td><td>by class: never / never / rand(30&ndash;365 days)</td><td><span class="badge badge-complete">Implemented</span> Public and post-key-signed comments never expire; only the open-slot stranger channel draws a TTL. A per-post policy (registry = flat 30d) overrides every class; 366-day holder ceiling (see <a href="#deletes">Comment retention</a>)</td></tr>
<tr><td>Open-slot comments per post</td><td>author-declared, clamped to 64 (greeting)</td><td><span class="badge badge-complete">Implemented</span> <code>OpenSlotDecl.max_comments</code>; authors may lower the holder default, never raise it</td></tr>
<!-- crypto.rs:522 per-recipient wrapping; ~500/256KB never enforced (audit B7) -->
<tr><td>Max recipients (per-recipient wrapping)</td><td>~500 / 256 KB</td><td>Design target for WrappedKey lists; not enforced in code</td></tr>
<tr><td>Public post encryption overhead</td><td>Zero</td><td>No WrappedKeys, unlimited audience</td></tr>
@ -2047,7 +2063,7 @@
<tr><td>AuthorManifest privacy (no device addresses, 2&ndash;5 neighborhood)</td><td><span class="badge badge-rework">Rework</span> &mdash; code ships addresses + 10+10 neighborhood</td></tr>
<tr><td>Engagement propagation via flat <code>file_holders</code> + signature verification</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Deletes as signed control posts (+ LRU blob orphaning)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Comment TTL expiry (rand 30&ndash;365d, fixed at creation)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Comment retention: (post policy) &times; (signing class); open-slot rand 30&ndash;365d, fixed at creation</td><td><span class="badge badge-complete">Implemented</span> &mdash; writer draw, holder-side enforcement in both directions, and the expiry sweep</td></tr>
<tr><td>Author concealment (2&ndash;5 visible posts) + traffic-uniformity shaping</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Push notifications, reactions, comments, tombstones, auto-sync on follow</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Audience sharding (large-audience optimization)</td><td><span class="badge badge-planned">Planned</span></td></tr>
@ -2082,7 +2098,7 @@
</div>
<div class="card">
<h3>2. Registry + greeting comments &mdash; tester-critical</h3>
<p><strong>The app is eminently useful only if people can find each other</strong> &mdash; this is what makes it usable for testers, so it jumps the queue. Ship the &ldquo;registrations here&rdquo; post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, holder-side rate/size caps &mdash; no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) &mdash; one open-slot comment implementation serves both. <strong>Prerequisite folded in</strong>: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.</p>
<p><strong>The app is eminently useful only if people can find each other</strong> &mdash; this is what makes it usable for testers, so it jumps the queue. Ship the &ldquo;registrations here&rdquo; post (open-slot signed registration comments, a flat 30-day comment-TTL policy binding every comment on the post, one entry per persona, self-certifying signed deletes, holder-side rate/size caps &mdash; no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) &mdash; one open-slot comment implementation serves both. <strong>Prerequisite folded in</strong>: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.</p>
</div>
<div class="card">
<h3>3. Cruft purge</h3>
@ -2102,7 +2118,7 @@
</div>
<div class="card">
<h3>7. Update-cadence system</h3>
<p>Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness &times; relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. Comment TTL (rand 30&ndash;365d) lands with this work.</p>
<p>Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness &times; relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. (Comment retention shipped ahead of this work &mdash; see <a href="#deletes">Comment retention</a>.)</p>
</div>
<div class="card">
<h3>8. Raw-UDP EDM refactor</h3>

View file

@ -70,7 +70,14 @@
<li><strong>Preferred peers eliminated.</strong> The preferred tier existed to serve direct N+10 push/pull that the CDN's holder model replaced. Gone: the slot tier, the <code>preferred_peers</code> table, preferred-tree routing semantics, the rebalance priority pass, the relay candidate tiers, and the 7-day and 30-day watchers.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Upgrade note:</strong> v0.8.0-alpha cannot talk to v0.7.x at all &mdash; that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization).</p>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Comment retention &mdash; corrected</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Only anonymous comments expire now.</strong> Retention is decided by the post's policy crossed with how the comment was signed. Public comments and comments from authorized (post-key-signed) commenters <em>never</em> auto-expire &mdash; an earlier build gave them a randomized 30&ndash;365 day lifetime, which would have deleted ordinary discussion on a timer. The randomized expiry now applies only to the private, unsigned channel strangers use, which is what retires throwaway conversation identities. Registry posts declare a flat 30-day policy binding every comment on them, so the directory self-cleans.</li>
<li><strong>Authors can cap or refuse anonymous comments per post</strong>, enforced by the nodes holding the post rather than trusted to the sender.</li>
<li><strong>Gated posts sync correctly.</strong> Several queries rebuilt posts without their gating metadata, so any gated post &mdash; including every bio carrying a greeting slot &mdash; failed its content-hash check at the receiver and was silently discarded. Greeting-enabled profiles now propagate as intended.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Upgrade note:</strong> v0.8.0-alpha cannot talk to v0.7.x at all &mdash; that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization). Android installs over an earlier 0.8.0-alpha may need an uninstall first, since the version code is unchanged.</p>
<h2 style="margin-top: 2rem;">v0.7.3 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.</p>